diff --git a/.gitattributes b/.gitattributes index c46415a5c..eb0c94757 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,10 @@ *.ex diff=elixir *.exs diff=elixir + +priv/static/instance/static.css diff=css + +# Most of js/css files included in the repo are minified bundles, +# and we don't want to search/diff those as text files. +*.js binary +*.js.map binary +*.css binary diff --git a/.gitignore b/.gitignore index 599b52b9e..62ca61bce 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /db /deps /*.ez +/test/instance /test/uploads /.elixir_ls /test/fixtures/DSCN0010_tmp.jpg @@ -27,6 +28,8 @@ erl_crash.dump # variables. /config/*.secret.exs /config/generated_config.exs +/config/*.env + # Database setup file, some may forget to delete it /config/setup_db.psql diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e65cae9d8..9ef3ddd0d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,7 +57,7 @@ unit-testing: policy: pull services: - - name: postgres:9.6 + - name: postgres:13 alias: postgres command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] script: @@ -198,7 +198,7 @@ amd64: variables: &release-variables MIX_ENV: prod before_script: &before-release - - apt-get update && apt-get install -y cmake + - apt-get update && apt-get install -y cmake libmagic-dev - echo "import Mix.Config" > config/prod.secret.exs - mix local.hex --force - mix local.rebar --force @@ -217,7 +217,7 @@ amd64-musl: cache: *release-cache variables: *release-variables before_script: &before-release-musl - - apk add git gcc g++ musl-dev make cmake + - apk add git gcc g++ musl-dev make cmake file-dev - echo "import Mix.Config" > config/prod.secret.exs - mix local.hex --force - mix local.rebar --force @@ -228,8 +228,8 @@ arm: artifacts: *release-artifacts only: *release-only tags: - - arm32 - image: elixir:1.10.3 + - arm32-specified + image: arm32v7/elixir:1.10.3 cache: *release-cache variables: *release-variables before_script: *before-release @@ -240,8 +240,8 @@ arm-musl: artifacts: *release-artifacts only: *release-only tags: - - arm32 - image: elixir:1.10.3-alpine + - arm32-specified + image: arm32v7/elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: *before-release-musl @@ -253,7 +253,7 @@ arm64: only: *release-only tags: - arm - image: elixir:1.10.3 + image: arm64v8/elixir:1.10.3 cache: *release-cache variables: *release-variables before_script: *before-release @@ -265,8 +265,7 @@ arm64-musl: only: *release-only tags: - arm - # TODO: Replace with upstream image when 1.9.0 comes out - image: elixir:1.10.3-alpine + image: arm64v8/elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: *before-release-musl diff --git a/CHANGELOG.md b/CHANGELOG.md index 1318b745f..b35fc159e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,41 +1,111 @@ # Changelog + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Changed + +- Polls now always return a `voters_count`, even if they are single-choice. +- Admin Emails: The ap id is used as the user link in emails now. +- Improved registration workflow for email confirmation and account approval modes. +- **Breaking:** Changed `mix pleroma.user toggle_confirmed` to `mix pleroma.user confirm` +- Search: When using Postgres 11+, Pleroma will use the `websearch_to_tsvector` function to parse search queries. +- Emoji: Support the full Unicode 13.1 set of Emoji for reactions, plus regional indicators. + ### Added -- Mix tasks for controlling user account confirmation status in bulk (`mix pleroma.user confirm_all` and `mix pleroma.user unconfirm_all`) -- Mix task for sending confirmation emails to all unconfirmed users (`mix pleroma.email send_confirmation_mails`) -- Mix task option for force-unfollowing relays + +- Reports now generate notifications for admins and mods. +- Experimental websocket-based federation between Pleroma instances. +- Support for local-only statuses. +- Support pagination of blocks and mutes. +- Account backup. +- Configuration: Add `:instance, autofollowing_nicknames` setting to provide a way to make accounts automatically follow new users that register on the local Pleroma instance. +- Ability to view remote timelines, with ex. `/api/v1/timelines/public?instance=lain.com` and streams `public:remote` and `public:remote:media`. +- The site title is now injected as a `title` tag like preloads or metadata. +- Password reset tokens now are not accepted after a certain age. +- Mix tasks to help with displaying and removing ConfigDB entries. See `mix pleroma.config`. +- OAuth form improvements: users are remembered by their cookie, the CSS is overridable by the admin, and the style has been improved. +- OAuth improvements and fixes: more secure session-based authentication (by token that could be revoked anytime), ability to revoke belonging OAuth token from any client etc. - Ability to set ActivityPub aliases for follower migration. +
+ API Changes +- Admin API: (`GET /api/pleroma/admin/users`) filter users by `unconfirmed` status and `actor_type`. +- Pleroma API: Add `idempotency_key` to the chat message entity that can be used for optimistic message sending. +- Pleroma API: (`GET /api/v1/pleroma/federation_status`) Add a way to get a list of unreachable instances. +- Mastodon API: User and conversation mutes can now auto-expire if `expires_in` parameter was given while adding the mute. +- Admin API: An endpoint to manage frontends. +- Streaming API: Add follow relationships updates. +
+ +### Fixed + +- Users with `is_discoverable` field set to false (default value) will appear in in-service search results but be hidden from external services (search bots etc.). +- Streaming API: Posts and notifications are not dropped, when CLI task is executing. +- Creating incorrect IPv4 address-style HTTP links when encountering certain numbers. + +
+ API Changes + - Mastodon API: Current user is now included in conversation if it's the only participant. + - Mastodon API: Fixed last_status.account being not filled with account data. +
+ +## Unreleased (Patch) + +### Fixed + +- Fix ability to update Pleroma Chat push notifications with PUT /api/v1/push/subscription and alert type pleroma:chat_mention +- Emoji Reaction activity filtering from blocked and muted accounts. + +## [2.2.1] - 2020-12-22 + +### Changed +- Updated Pleroma FE + +### Fixed + +- Config generation: rename `Pleroma.Upload.Filter.ExifTool` to `Pleroma.Upload.Filter.Exiftool`. +- S3 Uploads with Elixir 1.11. +- Mix task pleroma.user delete_activities for source installations. +- Search: RUM index search speed has been fixed. +- Rich Media Previews sometimes showed the wrong preview due to a bug following redirects. +- Fixes for the autolinker. +- Forwarded reports duplication from Pleroma instances. + +-
+ API + - Statuses were not displayed for Mastodon forwarded reports. +
+ +### Upgrade notes + +1. Restart Pleroma + +## [2.2.0] - 2020-11-12 + +### Security + +- Fixed the possibility of using file uploads to spoof posts. + ### Changed - **Breaking** Requires `libmagic` (or `file`) to guess file types. +- **Breaking:** App metrics endpoint (`/api/pleroma/app_metrics`) is disabled by default, check `docs/API/prometheus.md` on enabling and configuring. - **Breaking:** Pleroma Admin API: emoji packs and files routes changed. - **Breaking:** Sensitive/NSFW statuses no longer disable link previews. - Search: Users are now findable by their urls. - Renamed `:await_up_timeout` in `:connections_pool` namespace to `:connect_timeout`, old name is deprecated. - Renamed `:timeout` in `pools` namespace to `:recv_timeout`, old name is deprecated. - The `discoverable` field in the `User` struct will now add a NOINDEX metatag to profile pages when false. -- Users with the `discoverable` field set to false will not show up in searches. +- Users with the `is_discoverable` field set to false will not show up in searches ([bug](https://git.pleroma.social/pleroma/pleroma/-/issues/2301)). - Minimum lifetime for ephmeral activities changed to 10 minutes and made configurable (`:min_lifetime` option). - Introduced optional dependencies on `ffmpeg`, `ImageMagick`, `exiftool` software packages. Please refer to `docs/installation/optional/media_graphics_packages.md`. - -### Added -- Media preview proxy (requires `ffmpeg` and `ImageMagick` to be installed and media proxy to be enabled; see `:media_preview_proxy` config for more details). -- Pleroma API: Importing the mutes users from CSV files. -- Experimental websocket-based federation between Pleroma instances. - -
+-
API Changes - -- Pleroma API: Importing the mutes users from CSV files. -- Admin API: Importing emoji from a zip file -- Pleroma API: Pagination for remote/local packs and emoji. - +- API: Empty parameter values for integer parameters are now ignored in non-strict validaton mode.
### Removed @@ -46,15 +116,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Removed `:managed_config` option. In practice, it was accidentally removed with 2.0.0 release when frontends were switched to a new configuration mechanism, however it was not officially removed until now. +### Added + +- Media preview proxy (requires `ffmpeg` and `ImageMagick` to be installed and media proxy to be enabled; see `:media_preview_proxy` config for more details). +- Mix tasks for controlling user account confirmation status in bulk (`mix pleroma.user confirm_all` and `mix pleroma.user unconfirm_all`) +- Mix task for sending confirmation emails to all unconfirmed users (`mix pleroma.email resend_confirmation_emails`) +- Mix task option for force-unfollowing relays +- App metrics: ability to restrict access to specified IP whitelist. + +
+ API Changes + +- Admin API: Importing emoji from a zip file +- Pleroma API: Importing the mutes users from CSV files. +- Pleroma API: Pagination for remote/local packs and emoji. + +
+ ### Fixed - Add documented-but-missing chat pagination. - Allow sending out emails again. +- Allow sending chat messages to yourself +- OStatus / static FE endpoints: fixed inaccessibility for anonymous users on non-federating instances, switched to handling per `:restrict_unauthenticated` setting. +- Fix remote users with a whitespace name. -## Unreleased (Patch) +### Upgrade notes -### Changed -- API: Empty parameter values for integer parameters are now ignored in non-strict validaton mode. +1. Install libmagic and development headers (`libmagic-dev` on Ubuntu/Debian, `file-dev` on Alpine Linux) +2. Run database migrations (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl migrate` + - From Source: `mix ecto.migrate` +3. Restart Pleroma ## [2.1.2] - 2020-09-17 diff --git a/Dockerfile b/Dockerfile index c210cf79c..b1b5171af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY . . ENV MIX_ENV=prod -RUN apk add git gcc g++ musl-dev make cmake &&\ +RUN apk add git gcc g++ musl-dev make cmake file-dev &&\ echo "import Mix.Config" > config/prod.secret.exs &&\ mix local.hex --force &&\ mix local.rebar --force &&\ @@ -31,9 +31,9 @@ LABEL maintainer="ops@pleroma.social" \ ARG HOME=/opt/pleroma ARG DATA=/var/lib/pleroma -RUN echo "https://nl.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories &&\ +RUN echo "http://nl.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories &&\ apk update &&\ - apk add exiftool imagemagick ncurses postgresql-client &&\ + apk add exiftool imagemagick libmagic ncurses postgresql-client &&\ adduser --system --shell /bin/false --home ${HOME} pleroma &&\ mkdir -p ${DATA}/uploads &&\ mkdir -p ${DATA}/static &&\ diff --git a/SECURITY.md b/SECURITY.md index 8617c1434..c009d21d9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ Currently, Pleroma offers bugfixes and security patches only for the latest mino | Version | Support |---------| -------- -| 2.1 | Bugfixes and security patches +| 2.2 | Bugfixes and security patches ## Reporting a vulnerability diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex index 6cf3958c1..34a904ac2 100644 --- a/benchmarks/load_testing/users.ex +++ b/benchmarks/load_testing/users.ex @@ -109,8 +109,8 @@ def make_friends(main_user, max) when is_integer(max) do end def make_friends(%User{} = main_user, %User{} = user) do - {:ok, _} = User.follow(main_user, user) - {:ok, _} = User.follow(user, main_user) + {:ok, _, _} = User.follow(main_user, user) + {:ok, _, _} = User.follow(user, main_user) end @spec get_users(User.t(), keyword()) :: [User.t()] diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex b/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex index 9b7ac6111..aed32f194 100644 --- a/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex +++ b/benchmarks/mix/tasks/pleroma/benchmarks/timelines.ex @@ -50,7 +50,7 @@ def run(_args) do ) users - |> Enum.each(fn {:ok, follower} -> Pleroma.User.follow(follower, user) end) + |> Enum.each(fn {:ok, follower, user} -> Pleroma.User.follow(follower, user) end) Benchee.run( %{ diff --git a/config/config.exs b/config/config.exs index 2c6142360..d6d116314 100644 --- a/config/config.exs +++ b/config/config.exs @@ -47,7 +47,6 @@ config :pleroma, ecto_repos: [Pleroma.Repo] config :pleroma, Pleroma.Repo, - types: Pleroma.PostgresTypes, telemetry_event: [Pleroma.Repo.Instrumenter], migration_lock: nil @@ -123,14 +122,12 @@ # Configures the endpoint config :pleroma, Pleroma.Web.Endpoint, - instrumenters: [Pleroma.Web.Endpoint.Instrumenter], url: [host: "localhost"], http: [ ip: {127, 0, 0, 1}, dispatch: [ {:_, [ - {"/api/fedsocket/v1", Pleroma.Web.FedSockets.IncomingHandler, []}, {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, {"/websocket", Phoenix.Endpoint.CowboyWebSocket, {Phoenix.Transports.WebSocket, @@ -143,22 +140,12 @@ secret_key_base: "aK4Abxf29xU9TTDKre9coZPUgevcVCFQJe/5xP/7Lt4BEif6idBIbjupVbOrbKxl", signing_salt: "CqaoopA2", render_errors: [view: Pleroma.Web.ErrorView, accepts: ~w(json)], - pubsub: [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2], + pubsub_server: Pleroma.PubSub, secure_cookie_flag: true, extra_cookie_attrs: [ "SameSite=Lax" ] -config :pleroma, :fed_sockets, - enabled: false, - connection_duration: :timer.hours(8), - rejection_duration: :timer.minutes(15), - fed_socket_fetches: [ - default: 12_000, - interval: 3_000, - lazy: false - ] - # Configures Elixir's Logger config :logger, :console, level: :debug, @@ -235,6 +222,7 @@ "text/bbcode" ], autofollowed_nicknames: [], + autofollowing_nicknames: [], max_pinned_statuses: 1, attachment_links: false, max_report_comment_size: 1000, @@ -264,7 +252,8 @@ length: 16 ] ], - show_reactions: true + show_reactions: true, + password_reset_token_validity: 60 * 60 * 24 config :pleroma, :welcome, direct_message: [ @@ -316,7 +305,7 @@ hideSitename: false, hideUserStats: false, loginMethod: "password", - logo: "/static/logo.png", + logo: "/static/logo.svg", logoMargin: ".1em", logoMask: true, minimalScopesMode: false, @@ -353,8 +342,8 @@ config :pleroma, :manifest, icons: [ %{ - src: "/static/logo.png", - type: "image/png" + src: "/static/logo.svg", + type: "image/svg+xml" } ], theme_color: "#282c37", @@ -551,6 +540,7 @@ queues: [ activity_expiration: 10, token_expiration: 5, + backup: 1, federator_incoming: 50, federator_outgoing: 50, ingestion_queue: 50, @@ -561,7 +551,8 @@ background: 5, remote_fetcher: 2, attachments_cleanup: 5, - new_users_digest: 1 + new_users_digest: 1, + mute_expire: 5 ], plugins: [Oban.Plugins.Pruner], crontab: [ @@ -636,7 +627,12 @@ config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: false -config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, path: "/api/pleroma/app_metrics" +config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, + enabled: false, + auth: false, + ip_whitelist: [], + path: "/api/pleroma/app_metrics", + format: :text config :pleroma, Pleroma.ScheduledActivity, daily_user_limit: 25, @@ -651,7 +647,7 @@ } config :pleroma, :oauth2, - token_expires_in: 600, + token_expires_in: 3600 * 24 * 365 * 100, issue_new_refresh_token: true, clean_expired_tokens: false @@ -814,7 +810,7 @@ config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false config :pleroma, :mrf, - policies: Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, + policies: [Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, Pleroma.Web.ActivityPub.MRF.TagPolicy], transparency: true, transparency_exclusions: [] @@ -830,6 +826,11 @@ config :pleroma, Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.PleromaAuthenticator +config :pleroma, Pleroma.User.Backup, + purge_after_days: 30, + limit_days: 7, + dir: nil + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/description.exs b/config/description.exs index 2a1898922..cf004f0cf 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1,5 +1,4 @@ use Mix.Config -alias Pleroma.Docs.Generator websocket_config = [ path: "/websocket", @@ -273,19 +272,6 @@ } ] }, - %{ - group: :pleroma, - key: :fed_sockets, - type: :group, - description: "Websocket based federation", - children: [ - %{ - key: :enabled, - type: :boolean, - description: "Enable FedSockets" - } - ] - }, %{ group: :pleroma, key: Pleroma.Emails.Mailer, @@ -829,13 +815,13 @@ key: :autofollowed_nicknames, type: {:list, :string}, description: - "Set to nicknames of (local) users that every new user should automatically follow", - suggestions: [ - "lain", - "kaniini", - "lanodan", - "rinpatch" - ] + "Set to nicknames of (local) users that every new user should automatically follow" + }, + %{ + key: :autofollowing_nicknames, + type: {:list, :string}, + description: + "Set to nicknames of (local) users that automatically follows every newly registered user" }, %{ key: :attachment_links, @@ -1268,7 +1254,7 @@ hideSitename: false, hideUserStats: false, loginMethod: "password", - logo: "/static/logo.png", + logo: "/static/logo.svg", logoMargin: ".1em", logoMask: true, minimalScopesMode: false, @@ -1354,7 +1340,7 @@ key: :logo, type: {:string, :image}, description: "URL of the logo, defaults to Pleroma's logo", - suggestions: ["/static/logo.png"] + suggestions: ["/static/logo.svg"] }, %{ key: :logoMargin, @@ -1555,289 +1541,6 @@ } ] }, - %{ - 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" - ] - } - ] - }, - %{ - group: :pleroma, - key: :mrf_simple, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.SimplePolicy", - label: "MRF Simple", - type: :group, - 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"] - } - ] - }, - %{ - group: :pleroma, - key: :mrf_activity_expiration, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy", - label: "MRF Activity Expiration Policy", - type: :group, - 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] - } - ] - }, - %{ - group: :pleroma, - key: :mrf_subchain, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.SubchainPolicy", - label: "MRF Subchain", - type: :group, - 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] - } - ] - } - ] - }, - %{ - group: :pleroma, - key: :mrf_rejectnonpublic, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.RejectNonPublic", - description: "RejectNonPublic drops posts with non-public visibility settings.", - label: "MRF Reject Non Public", - type: :group, - 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" - } - ] - }, - %{ - group: :pleroma, - key: :mrf_hellthread, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.HellthreadPolicy", - label: "MRF Hellthread", - type: :group, - 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] - } - ] - }, - %{ - group: :pleroma, - key: :mrf_keyword, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.KeywordPolicy", - label: "MRF Keyword", - type: :group, - description: "Reject or Word-Replace messages with a keyword or regex", - children: [ - %{ - key: :reject, - type: {:list, :string}, - description: - "A list of patterns which result in message being rejected. Each pattern can be a string or a regular expression.", - 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 a regular expression.", - suggestions: ["foo", ~r/foo/iu] - }, - %{ - key: :replace, - type: {:list, :tuple}, - description: - "A list of tuples containing {pattern, replacement}. Each pattern can be a string or a regular expression.", - suggestions: [{"foo", "bar"}, {~r/foo/iu, "bar"}] - } - ] - }, - %{ - group: :pleroma, - key: :mrf_mention, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.MentionPolicy", - label: "MRF Mention", - type: :group, - 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"] - } - ] - }, - %{ - group: :pleroma, - key: :mrf_vocabulary, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.VocabularyPolicy", - label: "MRF Vocabulary", - type: :group, - 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"] - } - ] - }, - # %{ - # group: :pleroma, - # key: :mrf_user_allowlist, - # tab: :mrf, - # related_policy: "Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy", - # 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"]} - # ] - # ] - # }, %{ group: :pleroma, key: :media_proxy, @@ -2250,14 +1953,8 @@ group: :pleroma, key: Oban, type: :group, - description: """ - [Oban](https://github.com/sorentwo/oban) asynchronous job processor configuration. - - Note: if you are running PostgreSQL in [`silent_mode`](https://postgresqlco.nf/en/doc/param/silent_mode?version=9.1), - it's advised to set [`log_destination`](https://postgresqlco.nf/en/doc/param/log_destination?version=9.1) to `syslog`, - otherwise `postmaster.log` file may grow because of "you don't own a lock of type ShareLock" warnings - (see https://github.com/sorentwo/oban/issues/52). - """, + description: + "[Oban](https://github.com/sorentwo/oban) asynchronous job processor configuration.", children: [ %{ key: :log, @@ -2288,6 +1985,12 @@ description: "Activity expiration queue", suggestions: [10] }, + %{ + key: :backup, + type: :integer, + description: "Backup queue", + suggestions: [1] + }, %{ key: :attachments_cleanup, type: :integer, @@ -2831,7 +2534,7 @@ key: :token_expires_in, type: :integer, description: "The lifetime in seconds of the access token", - suggestions: [600] + suggestions: [2_592_000] }, %{ key: :issue_new_refresh_token, @@ -3144,22 +2847,6 @@ } ] }, - %{ - group: :pleroma, - key: :mrf_normalize_markup, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.NormalizeMarkup", - label: "MRF Normalize Markup", - description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.", - type: :group, - children: [ - %{ - key: :scrub_policy, - type: :module, - suggestions: [Pleroma.HTML.Scrubber.Default] - } - ] - }, %{ group: :pleroma, key: Pleroma.User, @@ -3349,33 +3036,6 @@ } ] }, - %{ - group: :pleroma, - key: :mrf_object_age, - tab: :mrf, - related_policy: "Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy", - label: "MRF Object Age", - type: :group, - 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] - } - ] - }, %{ group: :pleroma, key: :modules, @@ -3722,5 +3382,62 @@ suggestions: [2] } ] + }, + %{ + group: :pleroma, + key: Pleroma.User.Backup, + type: :group, + description: "Account Backup", + children: [ + %{ + key: :purge_after_days, + type: :integer, + description: "Remove backup achives after N days", + suggestions: [30] + }, + %{ + key: :limit_days, + type: :integer, + description: "Limit user to export not more often than once per N days", + suggestions: [7] + } + ] + }, + %{ + group: :prometheus, + key: Pleroma.Web.Endpoint.MetricsExporter, + type: :group, + description: "Prometheus app metrics endpoint configuration", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "[Pleroma extension] Enables app metrics endpoint." + }, + %{ + key: :ip_whitelist, + type: [{:list, :string}, {:list, :charlist}, {:list, :tuple}], + description: + "[Pleroma extension] If non-empty, restricts access to app metrics endpoint to specified IP addresses." + }, + %{ + key: :auth, + type: [:boolean, :tuple], + description: "Enables HTTP Basic Auth for app metrics endpoint.", + suggestion: [false, {:basic, "myusername", "mypassword"}] + }, + %{ + key: :path, + type: :string, + description: "App metrics endpoint URI path.", + suggestions: ["/api/pleroma/app_metrics"] + }, + %{ + key: :format, + type: :atom, + description: "App metrics endpoint output format.", + suggestions: [:text, :protobuf] + } + ] } ] diff --git a/config/releases.exs b/config/releases.exs deleted file mode 100644 index 19636765f..000000000 --- a/config/releases.exs +++ /dev/null @@ -1,31 +0,0 @@ -import Config - -config :pleroma, :instance, static_dir: "/var/lib/pleroma/static" -config :pleroma, Pleroma.Uploaders.Local, uploads: "/var/lib/pleroma/uploads" -config :pleroma, :modules, runtime_dir: "/var/lib/pleroma/modules" - -config_path = System.get_env("PLEROMA_CONFIG_PATH") || "/etc/pleroma/config.exs" - -config :pleroma, release: true, config_path: config_path - -if File.exists?(config_path) do - import_config config_path -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) -end - -exported_config = - config_path - |> Path.dirname() - |> Path.join("prod.exported_from_db.secret.exs") - -if File.exists?(exported_config) do - import_config exported_config -end diff --git a/config/test.exs b/config/test.exs index 7cc660e3c..a85881592 100644 --- a/config/test.exs +++ b/config/test.exs @@ -19,11 +19,6 @@ level: :warn, format: "\n[$level] $message\n" -config :pleroma, :fed_sockets, - enabled: false, - connection_duration: 5, - rejection_duration: 5 - config :pleroma, :auth, oauth_consumer_strategies: [] config :pleroma, Pleroma.Upload, @@ -52,7 +47,10 @@ password: "postgres", database: "pleroma_test", hostname: System.get_env("DB_HOST") || "localhost", - pool: Ecto.Adapters.SQL.Sandbox + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 50 + +config :pleroma, :dangerzone, override_repo_pool_size: true # Reduce hash rounds for testing config :pbkdf2_elixir, rounds: 1 @@ -126,6 +124,16 @@ config :pleroma, :mrf, policies: [] +config :pleroma, :pipeline, + object_validator: Pleroma.Web.ActivityPub.ObjectValidatorMock, + mrf: Pleroma.Web.ActivityPub.MRFMock, + activity_pub: Pleroma.Web.ActivityPub.ActivityPubMock, + side_effects: Pleroma.Web.ActivityPub.SideEffectsMock, + federator: Pleroma.Web.FederatorMock, + config: Pleroma.ConfigMock + +config :pleroma, :cachex, provider: Pleroma.CachexMock + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 7bf13daef..266f8cef8 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -20,12 +20,14 @@ Configuration options: - `external`: only external users - `active`: only active users - `need_approval`: only unapproved users + - `unconfirmed`: only unconfirmed users - `deactivated`: only deactivated users - `is_admin`: users with admin role - `is_moderator`: users with moderator role - *optional* `page`: **integer** page number - *optional* `page_size`: **integer** number of users per page (default is `50`) - *optional* `tags`: **[string]** tags list + - *optional* `actor_types`: **[string]** actor type list (`Person`, `Service`, `Application`) - *optional* `name`: **string** user display name - *optional* `email`: **string** user email - Example: `https://mypleroma.org/api/pleroma/admin/users?query=john&filters=local,active&page=1&page_size=10&tags[]=some_tag&tags[]=another_tag&name=display_name&email=email@example.com` @@ -552,7 +554,7 @@ Response: * `show_role` * `skip_thread_containment` * `fields` - * `discoverable` + * `is_discoverable` * `actor_type` * Responses: @@ -1497,3 +1499,66 @@ Returns the content of the document "url": "https://example.com/instance/panel.html" } ``` + +## `GET /api/pleroma/admin/frontends + +### List available frontends + +- Response: + +```json +[ + { + "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/pleroma/fedi-fe", + "installed": true, + "name": "fedi-fe", + "ref": "master" + }, + { + "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/lambadalambda/kenoma", + "installed": false, + "name": "kenoma", + "ref": "master" + } +] +``` + +## `POST /api/pleroma/admin/frontends/install` + +### Install a frontend + +- Params: + - `name`: frontend name, required + - `ref`: frontend ref + - `file`: path to a frontend zip file + - `build_url`: build URL + - `build_dir`: build directory + +- Response: + +```json +[ + { + "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/pleroma/fedi-fe", + "installed": true, + "name": "fedi-fe", + "ref": "master" + }, + { + "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build", + "git": "https://git.pleroma.social/lambadalambda/kenoma", + "installed": false, + "name": "kenoma", + "ref": "master" + } +] +``` + +```json +{ + "error": "Could not install frontend" +} +``` diff --git a/docs/API/chats.md b/docs/API/chats.md index aa6119670..f50144c86 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -116,6 +116,10 @@ The modified chat message This will return a list of chats that you have been involved in, sorted by their last update (so new chats will be at the top). +Parameters: + +- with_muted: Include chats from muted users (boolean). + Returned data: ```json @@ -173,11 +177,14 @@ Returned data: "created_at": "2020-04-21T15:06:45.000Z", "emojis": [], "id": "12", - "unread": false + "unread": false, + "idempotency_key": "75442486-0874-440c-9db1-a7006c25a31f" } ] ``` +- idempotency_key: The copy of the `idempotency-key` HTTP request header that can be used for optimistic message sending. Included only during the first few minutes after the message creation. + ### Posting a chat message Posting a chat message for given Chat id works like this: diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 3cb2183bd..84430408b 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -4,17 +4,21 @@ A Pleroma instance can be identified by " (compatible; Pleroma ## Flake IDs -Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings +Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However, just like Mastodon's ids, they are lexically sortable strings ## Timelines Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. + Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`. + Adding the parameter `reply_visibility` to the public and home timelines queries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. +Adding the parameter `instance=lain.com` to the public timeline will show only statuses originating from `lain.com` (or any remote instance). + ## Statuses -- `visibility`: has an additional possible value `list` +- `visibility`: has additional possible values `list` and `local` (for local-only statuses) Has these additional fields under the `pleroma` object: @@ -22,8 +26,8 @@ Has these additional fields under the `pleroma` object: - `conversation_id`: the ID of the AP context the status is associated with (if any) - `direct_conversation_id`: the ID of the Mastodon direct message conversation the status is associated with (if any) - `in_reply_to_account_acct`: the `acct` property of User entity for replied user (if any) -- `content`: a map consisting of alternate representations of the `content` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain` -- `spoiler_text`: a map consisting of alternate representations of the `spoiler_text` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain` +- `content`: a map consisting of alternate representations of the `content` property with the key being its mimetype. Currently, the only alternate representation supported is `text/plain` +- `spoiler_text`: a map consisting of alternate representations of the `spoiler_text` property with the key being its mimetype. Currently, the only alternate representation supported is `text/plain` - `expires_at`: a datetime (iso8601) that states when the post will expire (be deleted automatically), or empty if the post won't expire - `thread_muted`: true if the thread the post belongs to is muted - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "โ˜•", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. @@ -80,7 +84,7 @@ Has these additional fields under the `pleroma` object: - `show_role`: boolean, nullable, true when the user wants his role (e.g admin, moderator) to be shown - `no_rich_text` - boolean, nullable, true when html tags are stripped from all statuses requested from the API -- `discoverable`: boolean, true when the user allows discovery of the account in search results and other services. +- `discoverable`: boolean, true when the user allows external services (search bots) etc. to index / list the account (regardless of this setting, user will still appear in regular search results) - `actor_type`: string, the type of this account. ## Conversations @@ -125,12 +129,30 @@ The `type` value is `pleroma:emoji_reaction`. Has these fields: - `account`: The account of the user who reacted - `status`: The status that was reacted on +### ChatMention Notification (not default) + +This notification has to be requested explicitly. + +The `type` value is `pleroma:chat_mention` + +- `account`: The account who sent the message +- `chat_message`: The chat message + +### Report Notification (not default) + +This notification has to be requested explicitly. + +The `type` value is `pleroma:report` + +- `account`: The account who reported +- `report`: The report + ## GET `/api/v1/notifications` Accepts additional parameters: - `exclude_visibilities`: will exclude the notifications for activities with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`). Usage example: `GET /api/v1/notifications?exclude_visibilities[]=direct&exclude_visibilities[]=private`. -- `include_types`: will include the notifications for activities with the given types. The parameter accepts an array of types (`mention`, `follow`, `reblog`, `favourite`, `move`, `pleroma:emoji_reaction`). Usage example: `GET /api/v1/notifications?include_types[]=mention&include_types[]=reblog`. +- `include_types`: will include the notifications for activities with the given types. The parameter accepts an array of types (`mention`, `follow`, `reblog`, `favourite`, `move`, `pleroma:emoji_reaction`, `pleroma:chat_mention`, `pleroma:report`). Usage example: `GET /api/v1/notifications?include_types[]=mention&include_types[]=reblog`. ## DELETE `/api/v1/notifications/destroy_multiple` @@ -148,10 +170,10 @@ Returns on success: 200 OK `{}` Additional parameters can be added to the JSON body/Form data: -- `preview`: boolean, if set to `true` the post won't be actually posted, but the status entitiy would still be rendered back. This could be useful for previewing rich text/custom emoji, for example. +- `preview`: boolean, if set to `true` the post won't be actually posted, but the status entity would still be rendered back. This could be useful for previewing rich text/custom emoji, for example. - `content_type`: string, contain the MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint. -- `to`: A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply. -- `visibility`: string, besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`. +- `to`: A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for post visibility are not affected by this and will still apply. +- `visibility`: string, besides standard MastoAPI values (`direct`, `private`, `unlisted`, `local` or `public`) it can be used to address a List by setting it to `list:LIST_ID`. - `expires_in`: The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour. - `in_reply_to_conversation_id`: 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`. @@ -186,7 +208,7 @@ Additional parameters can be added to the JSON body/Form data: - `allow_following_move` - if true, allows automatically follow moved following accounts - `also_known_as` - array of ActivityPub IDs, needed for following move - `pleroma_background_image` - sets the background image of the user. Can be set to "" (an empty string) to reset. -- `discoverable` - if true, discovery of this account in search results and other services is allowed. +- `discoverable` - if true, external services (search bots) etc. are allowed to index / list the account (regardless of this setting, user will still appear in regular search results). - `actor_type` - the type of this account. - `accepts_chat_messages` - if false, this account will reject all chat messages. @@ -212,7 +234,7 @@ Post here request with `grant_type=refresh_token` to obtain new access token. Re `POST /api/v1/accounts` -Has theses additional parameters (which are the same as in Pleroma-API): +Has these additional parameters (which are the same as in Pleroma-API): - `fullname`: optional - `bio`: optional @@ -240,6 +262,16 @@ Has theses additional parameters (which are the same as in Pleroma-API): - `pleroma.metadata.post_formats`: A list of the allowed post format types - `vapid_public_key`: The public key needed for push messages +## Push Subscription + +`POST /api/v1/push/subscription` +`PUT /api/v1/push/subscription` + +Permits these additional alert types: + +- pleroma:chat_mention +- pleroma:emoji_reaction + ## Markers Has these additional fields under the `pleroma` object: @@ -248,8 +280,31 @@ Has these additional fields under the `pleroma` object: ## Streaming +### Chats + There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. +### Remote timelines + +For viewing remote server timelines, there are `public:remote` and `public:remote:media` streams. Each of these accept a parameter like `?instance=lain.com`. + +### Follow relationships updates + +Pleroma streams follow relationships updates as `pleroma:follow_relationships_update` events to the `user` stream. + +The message payload consist of: + +- `state`: a relationship state, one of `follow_pending`, `follow_accept` or `follow_reject`. + +- `follower` and `following` maps with following fields: + - `id`: user ID + - `follower_count`: follower count + - `following_count`: following count + +## User muting and thread muting + +Both user muting and thread muting can be done for only a certain time by adding an `expires_in` parameter to the API calls and giving the expiration time in seconds. + ## Not implemented Pleroma is generally compatible with the Mastodon 2.7.2 API, but some newer features and non-essential features are omitted. These features usually return an HTTP 200 status code, but with an empty response. While they may be added in the future, they are considered low priority. diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 3fd141bd2..d8790ca32 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -579,14 +579,14 @@ Emoji reactions work a lot like favourites do. They make it possible to react to ### React to a post with a unicode emoji * Method: `PUT` * Authentication: required -* Params: `emoji`: A single character unicode emoji +* Params: `emoji`: A unicode RGI emoji or a regional indicator * Response: JSON, the status. ## `DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji` ### Remove a reaction to a post with a unicode emoji * Method: `DELETE` * Authentication: required -* Params: `emoji`: A single character unicode emoji +* Params: `emoji`: A unicode RGI emoji or a regional indicator * Response: JSON, the status. ## `GET /api/v1/pleroma/statuses/:id/reactions` @@ -615,3 +615,41 @@ Emoji reactions work a lot like favourites do. They make it possible to react to {"name": "๐Ÿ˜€", "count": 2, "me": true, "accounts": [{"id" => "xyz.."...}, {"id" => "zyx..."}]} ] ``` + +## `POST /api/v1/pleroma/backups` +### Create a user backup archive + +* Method: `POST` +* Authentication: required +* Params: none +* Response: JSON +* Example response: + +```json +[{ + "content_type": "application/zip", + "file_size": 0, + "inserted_at": "2020-09-10T16:18:03.000Z", + "processed": false, + "url": "https://example.com/media/backups/archive-foobar-20200910T161803-QUhx6VYDRQ2wfV0SdA2Pfj_2CLM_ATUlw-D5l5TJf4Q.zip" +}] +``` + +## `GET /api/v1/pleroma/backups` +### Lists user backups + +* Method: `GET` +* Authentication: not required +* Params: none +* Response: JSON +* Example response: + +```json +[{ + "content_type": "application/zip", + "file_size": 55457, + "inserted_at": "2020-09-10T16:18:03.000Z", + "processed": true, + "url": "https://example.com/media/backups/archive-foobar-20200910T161803-QUhx6VYDRQ2wfV0SdA2Pfj_2CLM_ATUlw-D5l5TJf4Q.zip" +}] +``` diff --git a/docs/API/prometheus.md b/docs/API/prometheus.md index 19c564e3c..a5158d905 100644 --- a/docs/API/prometheus.md +++ b/docs/API/prometheus.md @@ -2,15 +2,37 @@ Pleroma includes support for exporting metrics via the [prometheus_ex](https://github.com/deadtrickster/prometheus.ex) library. +Config example: + +``` +config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, + enabled: true, + auth: {:basic, "myusername", "mypassword"}, + ip_whitelist: ["127.0.0.1"], + path: "/api/pleroma/app_metrics", + format: :text +``` + +* `enabled` (Pleroma extension) enables the endpoint +* `ip_whitelist` (Pleroma extension) could be used to restrict access only to specified IPs +* `auth` sets the authentication (`false` for no auth; configurable to HTTP Basic Auth, see [prometheus-plugs](https://github.com/deadtrickster/prometheus-plugs#exporting) documentation) +* `format` sets the output format (`:text` or `:protobuf`) +* `path` sets the path to app metrics page + + ## `/api/pleroma/app_metrics` + ### Exports Prometheus application metrics + * Method: `GET` -* Authentication: not required +* Authentication: not required by default (see configuration options above) * Params: none -* Response: JSON +* Response: text ## Grafana + ### Config example + The following is a config example to use with [Grafana](https://grafana.com) ``` diff --git a/docs/administration/CLI_tasks/config.md b/docs/administration/CLI_tasks/config.md index 0923004b5..000ed4d98 100644 --- a/docs/administration/CLI_tasks/config.md +++ b/docs/administration/CLI_tasks/config.md @@ -32,7 +32,7 @@ config :pleroma, configurable_from_database: false ``` -To delete transfered settings from database optional flag `-d` can be used. `` is `prod` by default. +To delete transferred settings from database optional flag `-d` can be used. `` is `prod` by default. === "OTP" ```sh @@ -43,3 +43,111 @@ To delete transfered settings from database optional flag `-d` can be used. `] [-d] ``` + +## Dump all of the config settings defined in the database + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config dump + ``` + +=== "From Source" + + ```sh + mix pleroma.config dump + ``` + +## List individual configuration groups in the database + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config groups + ``` + +=== "From Source" + + ```sh + mix pleroma.config groups + ``` + +## Dump the saved configuration values for a specific group or key + +e.g., this shows all the settings under `config :pleroma` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config dump pleroma + ``` + +=== "From Source" + + ```sh + mix pleroma.config dump pleroma + ``` + +To get values under a specific key: + +e.g., this shows all the settings under `config :pleroma, :instance` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config dump pleroma instance + ``` + +=== "From Source" + + ```sh + mix pleroma.config dump pleroma instance + ``` + +## Delete the saved configuration values for a specific group or key + +e.g., this deletes all the settings under `config :tesla` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config delete [--force] tesla + ``` + +=== "From Source" + + ```sh + mix pleroma.config delete [--force] tesla + ``` + +To delete values under a specific key: + +e.g., this deletes all the settings under `config :phoenix, :stacktrace_depth` + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config delete [--force] phoenix stacktrace_depth + ``` + +=== "From Source" + + ```sh + mix pleroma.config delete [--force] phoenix stacktrace_depth + ``` + +## Remove all settings from the database + +This forcibly removes all saved values in the database. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config [--force] reset + ``` + +=== "From Source" + + ```sh + mix pleroma.config [--force] reset + ``` diff --git a/docs/administration/CLI_tasks/email.md b/docs/administration/CLI_tasks/email.md index d9aa0e71b..2bb57bea4 100644 --- a/docs/administration/CLI_tasks/email.md +++ b/docs/administration/CLI_tasks/email.md @@ -16,8 +16,7 @@ mix pleroma.email test [--to ] ``` - -Example: +Example: === "OTP" @@ -36,11 +35,11 @@ Example: === "OTP" ```sh - ./bin/pleroma_ctl email send_confirmation_mails + ./bin/pleroma_ctl email resend_confirmation_emails ``` === "From Source" ```sh - mix pleroma.email send_confirmation_mails + mix pleroma.email resend_confirmation_emails ``` diff --git a/docs/administration/CLI_tasks/frontend.md b/docs/administration/CLI_tasks/frontend.md index 7d1c1e937..d4a48cb56 100644 --- a/docs/administration/CLI_tasks/frontend.md +++ b/docs/administration/CLI_tasks/frontend.md @@ -1,12 +1,23 @@ # Managing frontends -`mix pleroma.frontend install [--ref ] [--file ] [--build-url ] [--path ] [--build-dir ]` +=== "OTP" + + ```sh + ./bin/pleroma_ctl frontend install [--ref ] [--file ] [--build-url ] [--path ] [--build-dir ] + ``` + +=== "From Source" + + ```sh + mix pleroma.frontend install [--ref ] [--file ] [--build-url ] [--path ] [--build-dir ] + ``` Frontend can be installed either from local zip file, or automatically downloaded from the web. -You can give all the options directly on the command like, but missing information will be filled out by looking at the data configured under `frontends.available` in the config files. +You can give all the options directly on the command line, but missing information will be filled out by looking at the data configured under `frontends.available` in the config files. + +Currently, known `` values are: -Currently known `` values are: - [admin-fe](https://git.pleroma.social/pleroma/admin-fe) - [kenoma](http://git.pleroma.social/lambadalambda/kenoma) - [pleroma-fe](http://git.pleroma.social/pleroma/pleroma-fe) @@ -19,51 +30,67 @@ You can still install frontends that are not configured, see below. For a frontend configured under the `available` key, it's enough to install it by name. -```sh tab="OTP" -./bin/pleroma_ctl frontend install pleroma -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.frontend install pleroma -``` + ```sh + ./bin/pleroma_ctl frontend install pleroma + ``` -This will download the latest build for the the pre-configured `ref` and install it. It can then be configured as the one of the served frontends in the config file (see `primary` or `admin`). +=== "From Source" -You can override any of the details. To install a pleroma build from a different url, you could do this: + ```sh + mix pleroma.frontend install pleroma + ``` -```sh tab="OPT" -./bin/pleroma_ctl frontend install pleroma --ref 2hu_edition --build-url https://example.org/raymoo.zip -``` +This will download the latest build for the pre-configured `ref` and install it. It can then be configured as the one of the served frontends in the config file (see `primary` or `admin`). -```sh tab="From Source" -mix pleroma.frontend install pleroma --ref 2hu_edition --build-url https://example.org/raymoo.zip -``` +You can override any of the details. To install a pleroma build from a different URL, you could do this: + +=== "OTP" + + ```sh + ./bin/pleroma_ctl frontend install pleroma --ref 2hu_edition --build-url https://example.org/raymoo.zip + ``` + +=== "From Source" + + ```sh + mix pleroma.frontend install pleroma --ref 2hu_edition --build-url https://example.org/raymoo.zip + ``` Similarly, you can also install from a local zip file. -```sh tab="OTP" -./bin/pleroma_ctl frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip -``` + ```sh + ./bin/pleroma_ctl frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip + ``` -The resulting frontend will always be installed into a folder of this template: `${instance_static}/frontends/${name}/${ref}` +=== "From Source" -Careful: This folder will be completely replaced on installation + ```sh + mix pleroma.frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip + ``` + +The resulting frontend will always be installed into a folder of this template: `${instance_static}/frontends/${name}/${ref}`. + +Careful: This folder will be completely replaced on installation. ## Example installation for an unknown frontend -The installation process is the same, but you will have to give all the needed options on the commond line. For example: +The installation process is the same, but you will have to give all the needed options on the command line. For example: -```sh tab="OTP" -./bin/pleroma_ctl frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip -``` + ```sh + ./bin/pleroma_ctl frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip + ``` -If you don't have a zip file but just want to install a frontend from a local path, you can simply copy the files over a folder of this template: `${instance_static}/frontends/${name}/${ref}` +=== "From Source" + + ```sh + mix pleroma.frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip + ``` + +If you don't have a zip file but just want to install a frontend from a local path, you can simply copy the files over a folder of this template: `${instance_static}/frontends/${name}/${ref}`. diff --git a/docs/administration/CLI_tasks/instance.md b/docs/administration/CLI_tasks/instance.md index d6913280a..982b22bf3 100644 --- a/docs/administration/CLI_tasks/instance.md +++ b/docs/administration/CLI_tasks/instance.md @@ -40,3 +40,5 @@ If any of the options are left unspecified, you will be prompted interactively. - `--strip-uploads ` - use ExifTool to strip uploads of sensitive location data - `--anonymize-uploads ` - randomize uploaded filenames - `--dedupe-uploads ` - store files based on their hash to reduce data storage requirements if duplicates are uploaded with different filenames +- `--skip-release-env` - skip generation the release environment file +- `--release-env-file` - release environment file path diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index c64ed4f22..b57dce0e7 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -264,13 +264,13 @@ === "OTP" ```sh - ./bin/pleroma_ctl user toggle_confirmed + ./bin/pleroma_ctl user confirm ``` === "From Source" ```sh - mix pleroma.user toggle_confirmed + mix pleroma.user confirm ``` ## Set confirmation status for all regular active users diff --git a/docs/ap_extensions.md b/docs/ap_extensions.md index c4550a1ac..3d1caeb3e 100644 --- a/docs/ap_extensions.md +++ b/docs/ap_extensions.md @@ -1,11 +1,41 @@ -# ChatMessages +# AP Extensions +## Actor endpoints -ChatMessages are the messages sent in 1-on-1 chats. They are similar to +The following endpoints are additionally present into our actors. + +- `oauthRegistrationEndpoint` (`http://litepub.social/ns#oauthRegistrationEndpoint`) +- `uploadMedia` (`https://www.w3.org/ns/activitystreams#uploadMedia`) + +### oauthRegistrationEndpoint + +Points to MastodonAPI `/api/v1/apps` for now. + +See + +### uploadMedia + +Inspired by , it is part of the ActivityStreams namespace because it used to be part of the ActivityPub specification and got removed from it. + +Content-Type: multipart/form-data + +Parameters: +- (required) `file`: The file being uploaded +- (optionnal) `description`: A plain-text description of the media, for accessibility purposes. + +Response: HTTP 201 Created with the object into the body, no `Location` header provided as it doesn't have an `id` + +The object given in the reponse should then be inserted into an Object's `attachment` field. + +## ChatMessages + +`ChatMessage`s are the messages sent in 1-on-1 chats. They are similar to `Note`s, but the addresing is done by having a single AP actor in the `to` field. Addressing multiple actors is not allowed. These messages are always private, there is no public version of them. They are created with a `Create` activity. +They are part of the `litepub` namespace as `http://litepub.social/ns#ChatMessage`. + Example: ```json diff --git a/docs/clients.md b/docs/clients.md index f84295b1f..3d81763e1 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -7,97 +7,105 @@ Feel free to contact us to be added to this list! - Homepage: - Source Code: - Platforms: Windows, Mac, Linux -- Features: Streaming Ready +- Features: MastoAPI, Streaming Ready ### Social - Source Code: - Contact: [@brainblasted@social.libre.fi](https://social.libre.fi/users/brainblasted) - Platforms: Linux (GNOME) - Note(2019-01-28): Not at a pre-alpha stage yet +- Features: MastoAPI ### Whalebird - Homepage: - Source Code: - Contact: [@h3poteto@pleroma.io](https://pleroma.io/users/h3poteto) - Platforms: Windows, Mac, Linux -- Features: Streaming Ready +- Features: MastoAPI, Streaming Ready ## Handheld +### AndStatus +- Homepage: +- Source Code: +- Platforms: Android +- Features: MastoAPI, ActivityPub (Client-to-Server) + ### Amaroq - Homepage: - Source Code: - Contact: [@eurasierboy@mastodon.social](https://mastodon.social/users/eurasierboy) - Platforms: iOS -- Features: No Streaming +- Features: MastoAPI, No Streaming ### Fedilab - Homepage: - Source Code: - Contact: [@fedilab@framapiaf.org](https://framapiaf.org/users/fedilab) - Platforms: Android -- Features: Streaming Ready, Moderation, Text Formatting +- Features: MastoAPI, Streaming Ready, Moderation, Text Formatting ### Kyclos - Source Code: - Platforms: SailfishOS -- Features: No Streaming +- Features: MastoAPI, No Streaming ### Husky - Source code: - Contact: [@Husky@enigmatic.observer](https://enigmatic.observer/users/Husky) - Platforms: Android -- Features: No Streaming, Emoji Reactions, Text Formatting, FE Stickers +- Features: MastoAPI, No Streaming, Emoji Reactions, Text Formatting, FE Stickers ### Fedi - Homepage: - Source Code: Proprietary, but gratis - Platforms: iOS, Android -- Features: Pleroma-specific features like Reactions +- Features: MastoAPI, Pleroma-specific features like Reactions ### Tusky - Homepage: - Source Code: - Contact: [@ConnyDuck@mastodon.social](https://mastodon.social/users/ConnyDuck) - Platforms: Android -- Features: No Streaming +- Features: MastoAPI, No Streaming ### Twidere - Homepage: - Source Code: - Contact: - Platform: Android -- Features: No Streaming +- Features: MastoAPI, No Streaming ### Indigenous - Homepage: - Source Code: -- Contact: [@realize.be@realize.be](@realize.be@realize.be) +- Contact: [@swentel@realize.be](https://realize.be) - Platforms: Android -- Features: No Streaming +- Features: MastoAPI, No Streaming ## Alternative Web Interfaces ### Brutaldon - Homepage: - Source Code: - Contact: [@gcupc@glitch.social](https://glitch.social/users/gcupc) -- Features: No Streaming +- Features: MastoAPI, No Streaming ### Halcyon - Source Code: - Contact: [@halcyon@social.csswg.org](https://social.csswg.org/users/halcyon) -- Features: Streaming Ready +- Features: MastoAPI, Streaming Ready ### Pinafore - Homepage: - Source Code: - Contact: [@pinafore@mastodon.technology](https://mastodon.technology/users/pinafore) - Note: Pleroma support is a secondary goal -- Features: No Streaming +- Features: MastoAPI, No Streaming ### Sengi - Homepage: - Source Code: - Contact: [@sengi_app@mastodon.social](https://mastodon.social/users/sengi_app) +- Features: MastoAPI ### DashFE - Source Code: @@ -107,3 +115,4 @@ Feel free to contact us to be added to this list! - Source Code: - Contact: [@r@freesoftwareextremist.com](https://freesoftwareextremist.com/users/r) - Features: Does not requires JavaScript +- Features: MastoAPI diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 0b13d7e88..85551362c 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -45,6 +45,7 @@ To add configuration to your config file, you can copy it from the base config. older software for theses nicknames. * `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature. * `autofollowed_nicknames`: Set to nicknames of (local) users that every new user should automatically follow. +* `autofollowing_nicknames`: Set to nicknames of (local) users that automatically follows every newly registered user. * `attachment_links`: Set to true to enable automatically adding attachment link text to statuses. * `max_report_comment_size`: The maximum size of the report comment (Default: `1000`). * `safe_dm_mentions`: If set to true, only mentions at the beginning of a post will be used to address people in direct messages. This is to prevent accidental mentioning of people when talking about them (e.g. "@friend hey i really don't like @enemy"). Default: `false`. @@ -62,6 +63,7 @@ To add configuration to your config file, you can copy it from the base config. * `external_user_synchronization`: Enabling following/followers counters synchronization for external users. * `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. * `show_reactions`: Let favourites and emoji reactions be viewed through the API (default: `true`). +* `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day). ## Welcome * `direct_message`: - welcome message sent as a direct message. @@ -219,18 +221,6 @@ config :pleroma, :mrf_user_allowlist, %{ * `total_user_limit`: the number of scheduled activities a user is allowed to create in total (Default: `300`) * `enabled`: whether scheduled activities are sent to the job queue to be executed -## FedSockets -FedSockets is an experimental feature allowing for Pleroma backends to federate using a persistant websocket connection as opposed to making each federation a seperate http connection. This feature is currently off by default. It is configurable throught he following options. - -### :fedsockets -* `enabled`: Enables FedSockets for this instance. `false` by default. -* `connection_duration`: Time an idle websocket is kept open. -* `rejection_duration`: Failures to connect via FedSockets will not be retried for this period of time. -* `fed_socket_fetches` and `fed_socket_rejections`: Settings passed to `cachex` for the fetch registry, and rejection stacks. See `Pleroma.Web.FedSockets` for more details. - - -## Frontends - ### :frontend_configurations This can be used to configure a keyword list that keeps the configuration data for any kind of frontend. By default, settings for `pleroma_fe` and `masto_fe` are configured. You can find the documentation for `pleroma_fe` configuration into [Pleroma-FE configuration and customization for instance administrators](/frontend/CONFIGURATION/#options). @@ -1077,6 +1067,20 @@ Control favicons for instances. * `enabled`: Allow/disallow displaying and getting instances favicons +## Pleroma.User.Backup + +!!! note + Requires enabled email + +* `:purge_after_days` an integer, remove backup achives after N days. +* `:limit_days` an integer, limit user to export not more often than once per N days. +* `:dir` a string with a path to backup temporary directory or `nil` to let Pleroma choose temporary directory in the following order: + 1. the directory named by the TMPDIR environment variable + 2. the directory named by the TEMP environment variable + 3. the directory named by the TMP environment variable + 4. C:\TMP on Windows or /tmp on Unix-like operating systems + 5. as a last resort, the current working directory + ## Frontend management Frontends in Pleroma are swappable - you can specify which one to use here. diff --git a/docs/configuration/howto_database_config.md b/docs/configuration/howto_database_config.md index 9ed4d6cdd..ae1462f9b 100644 --- a/docs/configuration/howto_database_config.md +++ b/docs/configuration/howto_database_config.md @@ -5,50 +5,37 @@ The configuration of Pleroma has traditionally been managed with a config file, ## Migration to database config -1. Run the mix task to migrate to the database. You'll receive some debugging output and a few messages informing you of what happened. +1. Run the mix task to migrate to the database. **Source:** - + ``` $ mix pleroma.config migrate_to_db ``` - + or - + **OTP:** - + *Note: OTP users need Pleroma to be running for `pleroma_ctl` commands to work* - + ``` $ ./bin/pleroma_ctl config migrate_to_db ``` ``` - 10:04:34.155 [debug] QUERY OK source="config" db=1.6ms decode=2.0ms queue=33.5ms idle=0.0ms - SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] Migrating settings from file: /home/pleroma/config/dev.secret.exs - - 10:04:34.240 [debug] QUERY OK db=4.5ms queue=0.3ms idle=92.2ms - TRUNCATE config; [] - - 10:04:34.244 [debug] QUERY OK db=2.8ms queue=0.3ms idle=97.2ms - ALTER SEQUENCE config_id_seq RESTART; [] - - 10:04:34.256 [debug] QUERY OK source="config" db=0.8ms queue=1.4ms idle=109.8ms - SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 WHERE ((c0."group" = $1) AND (c0."key" = $2)) [":pleroma", ":instance"] - - 10:04:34.292 [debug] QUERY OK db=2.6ms queue=1.7ms idle=137.7ms - INSERT INTO "config" ("group","key","value","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [":pleroma", ":instance", <<131, 108, 0, 0, 0, 1, 104, 2, 100, 0, 4, 110, 97, 109, 101, 109, 0, 0, 0, 7, 66, 108, 101, 114, 111, 109, 97, 106>>, ~N[2020-07-12 15:04:34], ~N[2020-07-12 15:04:34]] + Settings for key instance migrated. Settings for group :pleroma migrated. ``` - + 2. It is recommended to backup your config file now. ``` cp config/dev.secret.exs config/dev.secret.exs.orig ``` - + 3. Edit your Pleroma config to enable database configuration: ``` @@ -76,17 +63,17 @@ The configuration of Pleroma has traditionally been managed with a config file, config :pleroma, Pleroma.Web.Endpoint, url: [host: "cool.pleroma.site", scheme: "https", port: 443] - + config :pleroma, Pleroma.Repo, adapter: Ecto.Adapters.Postgres, username: "pleroma", password: "MySecretPassword", database: "pleroma_prod", hostname: "localhost" - + config :pleroma, configurable_from_database: true ``` - + 5. Restart your instance and you can now access the Settings tab in AdminFE. @@ -95,15 +82,15 @@ The configuration of Pleroma has traditionally been managed with a config file, 1. Run the mix task to migrate back from the database. You'll receive some debugging output and a few messages informing you of what happened. **Source:** - + ``` $ mix pleroma.config migrate_from_db ``` - + or - + **OTP:** - + ``` $ ./bin/pleroma_ctl config migrate_from_db ``` @@ -111,7 +98,7 @@ The configuration of Pleroma has traditionally been managed with a config file, ``` 10:26:30.593 [debug] QUERY OK source="config" db=9.8ms decode=1.2ms queue=26.0ms idle=0.0ms SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] - + 10:26:30.659 [debug] QUERY OK source="config" db=1.1ms idle=80.7ms SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] Database configuration settings have been saved to config/dev.exported_from_db.secret.exs @@ -124,30 +111,45 @@ The configuration of Pleroma has traditionally been managed with a config file, ## Debugging ### Clearing database config -You can clear the database config by truncating the `config` table in the database. e.g., +You can clear the database config with the following command: -``` -psql -d pleroma_dev -pleroma_dev=# TRUNCATE config; -TRUNCATE TABLE -``` + **Source:** + + ``` + $ mix pleroma.config reset + ``` + + or + + **OTP:** + + ``` + $ ./bin/pleroma_ctl config reset + ``` Additionally, every time you migrate the configuration to the database the config table is automatically truncated to ensure a clean migration. ### Manually removing a setting If you encounter a situation where the server cannot run properly because of an invalid setting in the database and this is preventing you from accessing AdminFE, you can manually remove the offending setting if you know which one it is. -e.g., here is an example showing a minimal configuration in the database. Only the `config :pleroma, :instance` settings are in the table: +e.g., here is an example showing a the removal of the `config :pleroma, :instance` settings: -``` -psql -d pleroma_dev -pleroma_dev=# select * from config; - id | key | value | inserted_at | updated_at | group -----+-----------+------------------------------------------------------------+---------------------+---------------------+---------- - 1 | :instance | \x836c0000000168026400046e616d656d00000007426c65726f6d616a | 2020-07-12 15:33:29 | 2020-07-12 15:33:29 | :pleroma -(1 row) -pleroma_dev=# delete from config where key = ':instance' and group = ':pleroma'; -DELETE 1 -``` + **Source:** + + ``` + $ mix pleroma.config delete pleroma instance + Are you sure you want to continue? [n] y + config :pleroma, :instance deleted from the ConfigDB. + ``` + + or + + **OTP:** + + ``` + $ ./bin/pleroma_ctl config delete pleroma instance + Are you sure you want to continue? [n] y + config :pleroma, :instance deleted from the ConfigDB. + ``` Now the `config :pleroma, :instance` settings have been removed from the database. diff --git a/docs/configuration/howto_ejabberd.md b/docs/configuration/howto_ejabberd.md new file mode 100644 index 000000000..520a0acbc --- /dev/null +++ b/docs/configuration/howto_ejabberd.md @@ -0,0 +1,136 @@ +# Configuring Ejabberd (XMPP Server) to use Pleroma for authentication + +If you want to give your Pleroma users an XMPP (chat) account, you can configure [Ejabberd](https://github.com/processone/ejabberd) to use your Pleroma server for user authentication, automatically giving every local user an XMPP account. + +In general, you just have to follow the configuration described at [https://docs.ejabberd.im/admin/configuration/authentication/#external-script](https://docs.ejabberd.im/admin/configuration/authentication/#external-script). Please read this section carefully. + +Copy the script below to suitable path on your system and set owner and permissions. Also do not forget adjusting `PLEROMA_HOST` and `PLEROMA_PORT`, if necessary. + +```bash +cp pleroma_ejabberd_auth.py /etc/ejabberd/pleroma_ejabberd_auth.py +chown ejabberd /etc/ejabberd/pleroma_ejabberd_auth.py +chmod 700 /etc/ejabberd/pleroma_ejabberd_auth.py +``` + +Set external auth params in ejabberd.yaml file: + +```bash +auth_method: [external] +extauth_program: "python3 /etc/ejabberd/pleroma_ejabberd_auth.py" +extauth_instances: 3 +auth_use_cache: false +``` + +Restart / reload your ejabberd service. + +After restarting your Ejabberd server, your users should now be able to connect with their Pleroma credentials. + + +```python +import sys +import struct +import http.client +from base64 import b64encode +import logging + + +PLEROMA_HOST = "127.0.0.1" +PLEROMA_PORT = "4000" +AUTH_ENDPOINT = "/api/v1/accounts/verify_credentials" +USER_ENDPOINT = "/api/v1/accounts" +LOGFILE = "/var/log/ejabberd/pleroma_auth.log" + +logging.basicConfig(filename=LOGFILE, level=logging.INFO) + + +# Pleroma functions +def create_connection(): + return http.client.HTTPConnection(PLEROMA_HOST, PLEROMA_PORT) + + +def verify_credentials(user: str, password: str) -> bool: + user_pass_b64 = b64encode("{}:{}".format( + user, password).encode('utf-8')).decode("ascii") + params = {} + headers = { + "Authorization": "Basic {}".format(user_pass_b64) + } + + try: + conn = create_connection() + conn.request("GET", AUTH_ENDPOINT, params, headers) + + response = conn.getresponse() + if response.status == 200: + return True + + return False + except Exception as e: + logging.info("Can not connect: %s", str(e)) + return False + + +def does_user_exist(user: str) -> bool: + conn = create_connection() + conn.request("GET", "{}/{}".format(USER_ENDPOINT, user)) + + response = conn.getresponse() + if response.status == 200: + return True + + return False + + +def auth(username: str, server: str, password: str) -> bool: + return verify_credentials(username, password) + + +def isuser(username, server): + return does_user_exist(username) + + +def read(): + (pkt_size,) = struct.unpack('>H', bytes(sys.stdin.read(2), encoding='utf8')) + pkt = sys.stdin.read(pkt_size) + cmd = pkt.split(':')[0] + if cmd == 'auth': + username, server, password = pkt.split(':', 3)[1:] + write(auth(username, server, password)) + elif cmd == 'isuser': + username, server = pkt.split(':', 2)[1:] + write(isuser(username, server)) + elif cmd == 'setpass': + # u, s, p = pkt.split(':', 3)[1:] + write(False) + elif cmd == 'tryregister': + # u, s, p = pkt.split(':', 3)[1:] + write(False) + elif cmd == 'removeuser': + # u, s = pkt.split(':', 2)[1:] + write(False) + elif cmd == 'removeuser3': + # u, s, p = pkt.split(':', 3)[1:] + write(False) + else: + write(False) + + +def write(result): + if result: + sys.stdout.write('\x00\x02\x00\x01') + else: + sys.stdout.write('\x00\x02\x00\x00') + sys.stdout.flush() + + +if __name__ == "__main__": + logging.info("Starting pleroma ejabberd auth daemon...") + while True: + try: + read() + except Exception as e: + logging.info( + "Error while processing data from ejabberd %s", str(e)) + pass + +``` \ No newline at end of file diff --git a/docs/configuration/optimizing_beam.md b/docs/configuration/optimizing_beam.md new file mode 100644 index 000000000..e336bd36c --- /dev/null +++ b/docs/configuration/optimizing_beam.md @@ -0,0 +1,66 @@ +# Optimizing the BEAM + +Pleroma is built upon the Erlang/OTP VM known as BEAM. The BEAM VM is highly optimized for latency, but this has drawbacks in environments without dedicated hardware. One of the tricks used by the BEAM VM is [busy waiting](https://en.wikipedia.org/wiki/Busy_waiting). This allows the application to pretend to be busy working so the OS kernel does not pause the application process and switch to another process waiting for the CPU to execute its workload. It does this by spinning for a period of time which inflates the apparent CPU usage of the application so it is immediately ready to execute another task. This can be observed with utilities like **top(1)** which will show consistently high CPU usage for the process. Switching between procesess is a rather expensive operation and also clears CPU caches further affecting latency and performance. The goal of busy waiting is to avoid this penalty. + +This strategy is very successful in making a performant and responsive application, but is not desirable on Virtual Machines or hardware with few CPU cores. Pleroma instances are often deployed on the same server as the required PostgreSQL database which can lead to situations where the Pleroma application is holding the CPU in a busy-wait loop and as a result the database cannot process requests in a timely manner. The fewer CPUs available, the more this problem is exacerbated. The latency is further amplified by the OS being installed on a Virtual Machine as the Hypervisor uses CPU time-slicing to pause the entire OS and switch between other tasks. + +More adventurous admins can be creative with CPU affinity (e.g., *taskset* for Linux and *cpuset* on FreeBSD) to pin processes to specific CPUs and eliminate much of this contention. The most important advice is to run as few processes as possible on your server to achieve the best performance. Even idle background processes can occasionally create [software interrupts](https://en.wikipedia.org/wiki/Interrupt) and take attention away from the executing process creating latency spikes and invalidation of the CPU caches as they must be cleared when switching between processes for security. + +Please only change these settings if you are experiencing issues or really know what you are doing. In general, there's no need to change these settings. + +## VPS Provider Recommendations + +### Good + +* Hetzner Cloud + +### Bad + +* AWS (known to use burst scheduling) + + +## Example configurations + +Tuning the BEAM requires you provide a config file normally called [vm.args](http://erlang.org/doc/man/erl.html#emulator-flags). If you are using systemd to manage the service you can modify the unit file as such: + +`ExecStart=/usr/bin/elixir --erl '-args_file /opt/pleroma/config/vm.args' -S /usr/bin/mix phx.server` + +Check your OS documentation to adopt a similar strategy on other platforms. + +### Virtual Machine and/or few CPU cores + +Disable the busy-waiting. This should generally only be done if you're on a platform that does burst scheduling, like AWS. + +**vm.args:** + +``` ++sbwt none ++sbwtdcpu none ++sbwtdio none +``` + +### Dedicated Hardware + +Enable more busy waiting, increase the internal maximum limit of BEAM processes and ports. You can use this if you run on dedicated hardware, but it is not necessary. + +**vm.args:** + +``` ++P 16777216 ++Q 16777216 ++K true ++A 128 ++sbt db ++sbwt very_long ++swt very_low ++sub true ++Mulmbcs 32767 ++Mumbcgs 1 ++Musmbcs 2047 +``` + +## Additional Reading + +* [WhatsApp: Scaling to Millions of Simultaneous Connections](https://www.erlang-factory.com/upload/presentations/558/efsf2012-whatsapp-scaling.pdf) +* [Preemptive Scheduling and Spinlocks](https://www.uio.no/studier/emner/matnat/ifi/nedlagte-emner/INF3150/h03/annet/slides/preemptive.pdf) +* [The Curious Case of BEAM CPU Usage](https://stressgrid.com/blog/beam_cpu_usage/) diff --git a/docs/configuration/static_dir.md b/docs/configuration/static_dir.md index 8ac07b725..a294bb604 100644 --- a/docs/configuration/static_dir.md +++ b/docs/configuration/static_dir.md @@ -88,3 +88,8 @@ config :pleroma, :frontend_configurations, Note the extra `static` folder for the terms-of-service.html Terms of Service will be shown to all users on the registration page. It's the best place where to write down the rules for your instance. You can modify the rules by adding and changing `$static_dir/static/terms-of-service.html`. + + +## Styling rendered pages + +To overwrite the CSS stylesheet of the OAuth form and other static pages, you can upload your own CSS file to `instance/static/static.css`. This will completely replace the CSS used by those pages, so it might be a good idea to copy the one from `priv/static/instance/static.css` and make your changes. diff --git a/docs/dev.md b/docs/dev.md index 22e0691f1..765380a58 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -14,10 +14,33 @@ This document contains notes and guidelines for Pleroma developers. For `:api` pipeline routes, it'll be verified whether `OAuthScopesPlug` was called or explicitly skipped, and if it was not then auth information will be dropped for request. Then `EnsurePublicOrAuthenticatedPlug` will be called to ensure that either the instance is not private or user is authenticated (unless explicitly skipped). Such automated checks help to prevent human errors and result in higher security / privacy for users. -## [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) +## Non-OAuth authentication -* With HTTP Basic Auth, OAuth scopes check is _not_ performed for any action (since password is provided during the auth, requester is able to obtain a token with full permissions anyways). `Pleroma.Web.Plugs.AuthenticationPlug` and `Pleroma.Web.Plugs.LegacyAuthenticationPlug` both call `Pleroma.Web.Plugs.OAuthScopesPlug.skip_plug(conn)` when password is provided. +* With non-OAuth authentication ([HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) or HTTP header- or params-provided auth), OAuth scopes check is _not_ performed for any action (since password is provided during the auth, requester is able to obtain a token with full permissions anyways); auth plugs invoke `Pleroma.Helpers.AuthHelper.skip_oauth(conn)` in this case. ## Auth-related configuration, OAuth consumer mode etc. See `Authentication` section of [the configuration cheatsheet](configuration/cheatsheet.md#authentication). + +## MRF policies descriptions + +If MRF policy depends on config, it can be added into MRF tab to adminFE by adding `config_description/0` method, which returns map with special structure. + +Example: + +```elixir +%{ + 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] + } + ] + } +``` diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index 6a9026d94..2b1c7406f 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -35,7 +35,7 @@ sudo apt full-upgrade * Install some of the above mentioned programs: ```shell -sudo apt install git build-essential postgresql postgresql-contrib cmake libmagic-devel +sudo apt install git build-essential postgresql postgresql-contrib cmake libmagic-dev ``` ### Install Elixir and Erlang @@ -101,6 +101,7 @@ sudo -Hu pleroma mix deps.get mv config/{generated_config.exs,prod.secret.exs} ``` + * The previous command creates also the file `config/setup_db.psql`, with which you can create the database: ```shell diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 62d4c8a72..63eda63ca 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -43,7 +43,7 @@ Other than things bundled in the OTP release Pleroma depends on: ### Installing optional packages -Per [`docs/installation/optional/media_graphics_packages.md`](docs/installation/optional/media_graphics_packages.md): +Per [`docs/installation/optional/media_graphics_packages.md`](optional/media_graphics_packages.md): * ImageMagick * ffmpeg * exiftool @@ -159,7 +159,7 @@ su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate" # su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" # Start the instance to verify that everything is working as expected -su pleroma -s $SHELL -lc "./bin/pleroma daemon" +su pleroma -s $SHELL -lc "export $(cat /opt/pleroma/config/pleroma.env); ./bin/pleroma daemon" # Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly sleep 20 && curl http://localhost:4000/api/v1/instance @@ -311,4 +311,3 @@ This will create an account withe the username of 'joeuser' with the email addre ## Questions Questions about the installation or didnโ€™t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. - diff --git a/installation/download-mastofe-build.sh b/installation/download-mastofe-build.sh index ee9e1c217..b8a021ef3 100755 --- a/installation/download-mastofe-build.sh +++ b/installation/download-mastofe-build.sh @@ -9,29 +9,32 @@ static_dir="instance/static" # project_branch="pleroma" # static_dir="priv/static" -if [[ ! -d "${static_dir}" ]] +if [ ! -d "${static_dir}" ] then echo "Error: ${static_dir} directory is missing, are you sure you are running this script at the root of pleromaโ€™s repository?" exit 1 fi -last_modified="$(curl -s -I 'https://git.pleroma.social/api/v4/projects/'${project_id}'/jobs/artifacts/'${project_branch}'/download?job=build' | grep '^Last-Modified:' | cut -d: -f2-)" +last_modified="$(curl --fail -s -I 'https://git.pleroma.social/api/v4/projects/'${project_id}'/jobs/artifacts/'${project_branch}'/download?job=build' | grep '^Last-Modified:' | cut -d: -f2-)" echo "branch:${project_branch}" echo "Last-Modified:${last_modified}" artifact="mastofe.zip" -if [[ -e mastofe.timestamp ]] && [[ "${last_modified}" != "" ]] +if [ "${last_modified}x" = "x" ] then - if [[ "$(cat mastofe.timestamp)" == "${last_modified}" ]] - then - echo "MastoFE is up-to-date, exitingโ€ฆ" - exit 0 - fi + echo "ERROR: Couldn't get the modification date of the latest build archive, maybe it expired, exiting..." + exit 1 fi -curl -c - "https://git.pleroma.social/api/v4/projects/${project_id}/jobs/artifacts/${project_branch}/download?job=build" -o "${artifact}" || exit +if [ -e mastofe.timestamp ] && [ "$(cat mastofe.timestamp)" = "${last_modified}" ] +then + echo "MastoFE is up-to-date, exiting..." + exit 0 +fi + +curl --fail -c - "https://git.pleroma.social/api/v4/projects/${project_id}/jobs/artifacts/${project_branch}/download?job=build" -o "${artifact}" || exit # TODO: Update the emoji as well rm -fr "${static_dir}/sw.js" "${static_dir}/packs" || exit diff --git a/installation/pleroma.nginx b/installation/pleroma.nginx index d613befd2..9890cb2b1 100644 --- a/installation/pleroma.nginx +++ b/installation/pleroma.nginx @@ -93,9 +93,4 @@ server { chunked_transfer_encoding on; proxy_pass http://phoenix; } - - location /api/fedsocket/v1 { - proxy_request_buffering off; - proxy_pass http://phoenix/api/fedsocket/v1; - } } diff --git a/installation/pleroma.service b/installation/pleroma.service index 5dcbc1387..8338228d8 100644 --- a/installation/pleroma.service +++ b/installation/pleroma.service @@ -29,8 +29,6 @@ ProtectHome=true ProtectSystem=full ; Sets up a new /dev mount for the process and only adds API pseudo devices like /dev/null, /dev/zero or /dev/random but not physical devices. Disabled by default because it may not work on devices like the Raspberry Pi. PrivateDevices=false -; Ensures that the service process and all its children can never gain new privileges through execve(). -NoNewPrivileges=true ; Drops the sysadmin capability from the daemon. CapabilityBoundingSet=~CAP_SYS_ADMIN diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 49ba2aae4..a33a9951c 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -12,17 +12,19 @@ defmodule Mix.Pleroma do :cachex, :flake_id, :swoosh, - :timex + :timex, + :fast_html ] - @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() Pleroma.Config.Oban.warn() + 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) @@ -36,12 +38,23 @@ def start_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) @@ -97,12 +110,6 @@ def shell_prompt(prompt, defval \\ nil, defname \\ nil) 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/config.ex b/lib/mix/tasks/pleroma/config.ex index 18f99318d..d7e2e97e7 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 @@ -14,26 +15,199 @@ defmodule Mix.Tasks.Pleroma.Config do @moduledoc File.read!("docs/administration/CLI_tasks/config.md") def run(["migrate_to_db"]) do - start_pleroma() - migrate_to_db() + check_configdb(fn -> + start_pleroma() + migrate_to_db() + end) end def run(["migrate_from_db" | options]) do + check_configdb(fn -> + start_pleroma() + + {opts, _} = + OptionParser.parse!(options, + strict: [env: :string, delete: :boolean], + aliases: [d: :delete] + ) + + migrate_from_db(opts) + end) + end + + def run(["dump"]) do + check_configdb(fn -> + start_pleroma() + + header = config_header() + + settings = + ConfigDB + |> Repo.all() + |> Enum.sort() + + unless settings == [] do + shell_info("#{header}") + + Enum.each(settings, &dump(&1)) + else + shell_error("No settings in ConfigDB.") + end + end) + end + + def run(["dump", group, key]) do + check_configdb(fn -> + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + group + |> ConfigDB.get_by_group_and_key(key) + |> dump() + end) + end + + def run(["dump", group]) do + check_configdb(fn -> + start_pleroma() + + group = maybe_atomize(group) + + dump_group(group) + end) + end + + def run(["groups"]) do + check_configdb(fn -> + start_pleroma() + + groups = + ConfigDB + |> distinct([c], true) + |> select([c], c.group) + |> Repo.all() + + 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", "--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() + + shell_info("The following settings will be permanently removed:") + + ConfigDB + |> Repo.all() + |> Enum.sort() + |> Enum.each(&dump(&1)) + + shell_error("\nTHIS CANNOT BE UNDONE!") + + 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 + shell_error("No changes made.") + end + end) + end + + def run(["delete", "--force", group, key]) do start_pleroma() - {opts, _} = - OptionParser.parse!(options, - strict: [env: :string, delete: :boolean], - aliases: [d: :delete] - ) + group = maybe_atomize(group) + key = maybe_atomize(key) - migrate_from_db(opts) + 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 + start_pleroma() + + group = maybe_atomize(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 + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(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 settings in ConfigDB for #{inspect(group)}, #{inspect(key)}. Aborting.") + end + end + + def run(["delete", group]) do + start_pleroma() + + group = maybe_atomize(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 settings in ConfigDB for #{inspect(group)}. Aborting.") + 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 @@ -47,16 +221,15 @@ def migrate_to_db(file_path \\ nil) do do_migrate_to_db(config_file) else - :error -> deprecation_error() - _ -> migration_error() + _ -> + shell_error("Migration is not allowed until all deprecation warnings have been resolved.") end end 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 @@ -80,52 +253,38 @@ defp create(group, settings) 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 - if Pleroma.Config.get([:configurable_from_database]) do - env = opts[:env] || Pleroma.Config.get(:env) + 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") + 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") - file = File.open!(config_path, [:write, :utf8]) + file = File.open!(config_path, [:write, :utf8]) - IO.write(file, config_header()) + IO.write(file, config_header()) - ConfigDB - |> Repo.all() - |> Enum.each(&write_and_delete(&1, file, opts[:delete])) + ConfigDB + |> Repo.all() + |> Enum.each(&write_and_delete(&1, file, opts[:delete])) - :ok = File.close(file) - System.cmd("mix", ["format", config_path]) + :ok = File.close(file) + System.cmd("mix", ["format", config_path]) - shell_info( - "Database configuration settings have been exported to config/#{env}.exported_from_db.secret.exs" - ) - else - migration_error() - end - end - - 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 - 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) @@ -150,8 +309,80 @@ defp write(config, file) do defp delete(config, true) do {:ok, _} = Repo.delete(config) - shell_info("#{config.key} deleted from DB.") + + shell_info( + "config #{inspect(config.group)}, #{inspect(config.key)} was deleted from the ConfigDB." + ) end defp delete(_config, _), do: :ok + + 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() + |> Enum.each(&dump/1) + end + + defp group_exists?(group) do + group + |> ConfigDB.get_all_by_group() + |> 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) + + defp maybe_atomize(arg) when is_binary(arg) do + if ConfigDB.module_name?(arg) do + String.to_existing_atom("Elixir." <> arg) + else + String.to_atom(arg) + end + end + + defp check_configdb(callback) do + with true <- Pleroma.Config.get([:configurable_from_database]) do + callback.() + else + _ -> + shell_error( + "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." + ) + end + end + + defp delete_key(group, key) do + check_configdb(fn -> + ConfigDB.delete(%{group: group, key: key}) + end) + end + + defp delete_group(group) do + check_configdb(fn -> + group + |> ConfigDB.get_all_by_group() + |> Enum.each(&ConfigDB.delete/1) + 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 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 @@ def run(["bump_all_conversations"]) 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/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 @@ def run(["install", "none" | _args]) do end def run(["install", frontend | args]) do - log_level = Logger.level() - Logger.configure(level: :warn) start_pleroma() {options, [], []} = @@ -33,109 +31,6 @@ def run(["install", frontend | args]) 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/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index fc21ae062..853c4eaa2 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -161,12 +161,21 @@ def run(["gen" | rest]) 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 = @@ -253,7 +262,7 @@ def run(["gen" | rest]) 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 @@ -284,7 +293,7 @@ defp write_robots_txt(static_dir, indexable, template_dir) 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 diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index a8d251411..20fe6c6e4 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -60,7 +60,7 @@ def run(["new", nickname, email | rest]) 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() @@ -345,11 +345,11 @@ def run(["delete_activities", nickname]) 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/phoenix/transports/web_socket/raw.ex b/lib/phoenix/transports/web_socket/raw.ex index aab7fad99..c3665bebe 100644 --- a/lib/phoenix/transports/web_socket/raw.ex +++ b/lib/phoenix/transports/web_socket/raw.ex @@ -31,7 +31,12 @@ def init(%Plug.Conn{method: "GET"} = conn, {endpoint, handler, transport}) do case conn do %{halted: false} = conn -> - case Transport.connect(endpoint, handler, transport, __MODULE__, nil, conn.params) do + case handler.connect(%{ + endpoint: endpoint, + transport: transport, + options: [serializer: nil], + params: conn.params + }) do {:ok, socket} -> {:ok, conn, {__MODULE__, {socket, opts}}} diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 17af04257..9d970a808 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 @@ -23,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) @@ -153,6 +156,18 @@ def get_bookmark(%Activity{} = activity, %User{} = user) 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]) @@ -181,6 +196,19 @@ def get_by_id(id) 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) @@ -272,7 +300,7 @@ def delete_all_by_object_ap_id(_), do: nil defp purge_web_resp_cache(%Activity{} = activity) do %{path: path} = URI.parse(activity.data["id"]) - Cachex.del(:web_resp_cache, path) + @cachex.del(:web_resp_cache, path) activity end @@ -343,4 +371,15 @@ def pinned_by_actor?(%Activity{} = 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() + |> first() + |> Repo.one() + end + + def get_by_object_ap_id_with_object(_), do: nil end 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 @@ defp visibility_tags(object, activity) 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 @@ defp hashtags_to_topics(%{data: %{"tag" => tags}}) 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/activity/search.ex b/lib/pleroma/activity/search.ex index ceb365bb3..babf9520b 100644 --- a/lib/pleroma/activity/search.ex +++ b/lib/pleroma/activity/search.ex @@ -19,15 +19,25 @@ def search(user, search_query, options \\ []) do offset = Keyword.get(options, :offset, 0) author = Keyword.get(options, :author) + search_function = + if :persistent_term.get({Pleroma.Repo, :postgres_version}) >= 11 do + :websearch + else + :plain + end + 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) - |> Pagination.fetch_paginated(%{"offset" => offset, "limit" => limit}, :offset) + |> Pagination.fetch_paginated( + %{"offset" => offset, "limit" => limit, "skip_order" => index_type == :rum}, + :offset + ) |> maybe_fetch(user, search_query) end @@ -50,7 +60,7 @@ defp restrict_public(q) 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 +71,18 @@ defp query_with(q, :gin, search_query) 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 +94,18 @@ defp query_with(q, :rum, search_query) 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) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 301b4e273..bd568d858 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -57,6 +57,7 @@ def start(_type, _args) do setup_instrumenters() load_custom_modules() Pleroma.Docs.JSON.compile() + limiters_setup() adapter = Application.get_env(:tesla, :adapter) @@ -100,7 +101,7 @@ def start(_type, _args) do ] ++ task_children(@env) ++ dont_run_in_test(@env) ++ - chat_child(@env, chat_enabled?()) ++ + chat_child(chat_enabled?()) ++ [ Pleroma.Web.Endpoint, Pleroma.Gopher.Server @@ -109,7 +110,28 @@ def start(_type, _args) 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 + + :persistent_term.put({Pleroma.Repo, :postgres_version}, version) end def load_custom_modules do @@ -151,7 +173,10 @@ defp setup_instrumenters 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 @@ -165,7 +190,11 @@ defp cachex_children do build_cachex("web_resp", limit: 2500), build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), build_cachex("failed_proxy_url", limit: 2500), - build_cachex("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 @@ -175,6 +204,9 @@ defp emoji_packs_expiration, 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])) @@ -197,16 +229,18 @@ defp dont_run_in_test(_) do name: Pleroma.Web.Streamer.registry(), keys: :duplicate, partitions: System.schedulers_online() - ]}, - Pleroma.Web.FedSockets.Supervisor + ]} ] end - defp chat_child(_env, true) do - [Pleroma.Web.ChatChannel.ChatChannelState] + defp chat_child(true) do + [ + Pleroma.Web.ChatChannel.ChatChannelState, + {Phoenix.PubSub, [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2]} + ] end - defp chat_child(_, _), do: [] + defp chat_child(_), do: [] defp task_children(:test) do [ @@ -260,4 +294,10 @@ defp http_children(Tesla.Adapter.Gun, _) do end 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)) + end end diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index b977257a3..e61576644 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -24,6 +24,7 @@ def verify! do |> check_migrations_applied!() |> check_welcome_message_config!() |> check_rum!() + |> check_repo_pool_size!() |> handle_result() end @@ -188,6 +189,30 @@ defp check_system_commands!(:ok) 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. + + 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."} + 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]) 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 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 @@ defp validate_expiration(created_at) 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 @@ defp validate_usage(token) 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/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/config.ex b/lib/pleroma/config.ex index 97f877595..86d4f6b72 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -3,14 +3,18 @@ # SPDX-License-Identifier: AGPL-3.0-only 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 +22,7 @@ def get([_ | _] = path, default) do end end + @impl true def get(key, default) do Application.get_env(:pleroma, key, default) end 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/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 @@ def default_config(group), do: Keyword.get(get_default(), group) 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 diff --git a/lib/pleroma/config_db.ex b/lib/pleroma/config_db.ex index e5b7811aa..8e8bb732f 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,8 +41,18 @@ def get_all_as_keyword 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_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 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/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 @@ def get_for_ap_id(ap_id) 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 @@ def create_or_bump_for(activity, opts \\ []) 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 @@ def mark_as_read(%User{} = user, %Conversation{} = conversation) 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 @@ def mark_all_as_read(%User{local: true} = user, %User{} = target_user) do |> update([p], set: [read: true]) |> Repo.update_all([]) - {:ok, user} = User.set_unread_conversation_count(user) {:ok, user, []} end @@ -108,7 +96,6 @@ def mark_all_as_read(%User{} = user) do |> select([p], p) |> Repo.update_all([]) - {:ok, user} = User.set_unread_conversation_count(user) {:ok, user, participations} end @@ -220,6 +207,12 @@ def set_recipients(participation, user_ids) 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/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/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index 8979db2f8..d5757c12a 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -18,10 +18,6 @@ defp instance_notify_email 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

@@ -52,6 +48,9 @@ def report(to, reporter, account, statuses, comment) do status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, id) "
  • #{status_url}
  • " + %{"id" => id} when is_binary(id) -> + "
  • #{id}
  • " + id when is_binary(id) -> "
  • #{id}
  • " end) @@ -69,8 +68,8 @@ def report(to, reporter, account, statuses, comment) 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 +85,7 @@ def report(to, reporter, account, statuses, comment) 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 """ diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 1d8c72ae9..d3625dbf2 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -93,6 +93,19 @@ def account_confirmation_email(user) 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 @@ -151,7 +164,7 @@ def digest_email(user) 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 +175,7 @@ def digest_email(user) 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 @@ -189,4 +202,30 @@ def unsubscribe_url(user, notifications_type) do Router.Helpers.subscription_url(Endpoint, :unsubscribe, token) end + + 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 = + 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 + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject("Your account archive is ready") + |> html_body(html_body) + end end 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 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..513fb59f8 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -102,31 +102,36 @@ defp update_emojis(emojis) do :ets.insert(@ets, emojis) end - @external_resource "lib/pleroma/emoji-data.txt" + @external_resource "lib/pleroma/emoji-test.txt" + + regional_indicators = + Enum.map(127_462..127_487, fn codepoint -> + <> + end) emojis = @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) |> 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() + emojis = emojis ++ regional_indicators + for emoji <- emojis do def is_unicode_emoji?(unquote(emoji)), do: true end diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 0670f29f1..ec97aa652 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -20,16 +20,18 @@ 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 @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 @@ -62,10 +64,9 @@ def show(opts) 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 @@ -94,7 +95,7 @@ defp unpack_zip_emojies(zip_files) 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 +283,21 @@ def update_metadata(name, data) 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 +305,6 @@ def load_pack(name) do |> length() {:ok, Map.put(pack, :files_count, files_count)} - else - {:error, :not_found} end end @@ -415,7 +417,7 @@ defp create_archive_and_cache(pack, hash) 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 @@ -434,10 +436,17 @@ defp save_pack(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 +500,10 @@ defp rename_file(pack, filename, new_filename) 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 +527,15 @@ defp remove_dir_if_empty(emoji, filename) 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 @@ -594,7 +608,7 @@ defp fetch_pack_info(remote_pack, uri, name) 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 @@ -606,7 +620,7 @@ defp download_archive(url, sha) 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 @@ -617,7 +631,7 @@ defp fallback_sha_changed?(pack, data) 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() diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 2039a259d..5390a58e1 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -62,23 +62,47 @@ def update(%User{} = follower, %User{} = following, state) do follow(follower, following, state) following_relationship -> - following_relationship - |> cast(%{state: state}, [:state]) - |> validate_required([:state]) - |> Repo.update() + 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("follow_relationship", %{ + state: state, + following: following, + follower: follower + }) + + {:ok, follower, following} end end diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex new file mode 100644 index 000000000..bf935a728 --- /dev/null +++ b/lib/pleroma/frontend.ex @@ -0,0 +1,110 @@ +# 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 + frontend_info = %{ + "ref" => opts[:ref], + "build_url" => opts[:build_url], + "build_dir" => opts[:build_dir] + } + + frontend_info = + [:frontends, :available, name] + |> Config.get(%{}) + |> Map.merge(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") + {:error, "Could not download or unzip the frontend"} + + _e -> + Logger.info("Could not install the frontend") + {:error, "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/helpers/auth_helper.ex b/lib/pleroma/helpers/auth_helper.ex new file mode 100644 index 000000000..8f87b38be --- /dev/null +++ b/lib/pleroma/helpers/auth_helper.ex @@ -0,0 +1,46 @@ +# 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 + 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. + """ + def skip_oauth(conn) do + conn + |> assign(:token, nil) + |> 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 +end 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/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 @@ def get_cached_scrubbed_html_for_activity( ) 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 @@ def extract_first_external_url_from_object(%{data: %{"content" => content}} = ob 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/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..2e1696fe2 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -77,7 +77,7 @@ def reachable?(url_or_host) when is_binary(url_or_host) 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), @@ -119,6 +119,17 @@ def set_unreachable(url_or_host, unreachable_since) when is_binary(url_or_host) 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 @@ -155,7 +166,8 @@ def get_or_update_favicon(%URI{host: host} = instance_uri) 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, @@ -164,7 +176,15 @@ defp scrape_favicon(%URI{} = instance_uri) 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 -> diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 38a863443..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,203 +110,105 @@ defp parse_datetime(datetime) 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 - }) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "report_update", - "subject" => report_to_map(subject), - "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 - }) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "report_note", - "subject" => report_to_map(subject), - "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", - subject: %Activity{} = subject, - text: text - }) do - %ModerationLog{ - data: %{ - "actor" => user_to_map(actor), - "action" => "report_note_delete", - "subject" => report_to_map(subject), - "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", + def insert_log( + %{ + actor: %User{}, + action: action, + subject: %Activity{} = subject, + sensitive: sensitive, + visibility: visibility + } = attrs + ) + 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() + "visibility" => visibility + }) + + 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: "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() + 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, 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{}, subject: subject, action: _action} = attrs) do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subject" => user_to_map(subject)}) + + 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{}, subjects: subjects, action: _action} = attrs) do + data = + attrs + |> prepare_log_data + |> Map.merge(%{"subjects" => user_to_map(subjects)}) - %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{}, + 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(), 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() - 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, @@ -305,27 +227,16 @@ def insert_log(%{ |> 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: %{ @@ -345,32 +256,27 @@ defp insert_log_entry_with_message(entry) 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", - "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}, @@ -382,7 +288,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -393,7 +298,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -404,7 +308,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -415,7 +318,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -426,7 +328,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -437,7 +338,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -451,7 +351,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -465,7 +364,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -477,7 +375,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -489,7 +386,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -500,7 +396,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -511,42 +406,48 @@ def get_log_entry_message(%ModerationLog{ "@#{actor_nickname} unfollowed relay: #{target}" 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() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -559,7 +460,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -572,7 +472,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -587,7 +486,6 @@ def get_log_entry_message(%ModerationLog{ }'" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -598,7 +496,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -609,7 +506,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -620,7 +516,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -633,7 +528,6 @@ def get_log_entry_message(%ModerationLog{ }" end - @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ "actor" => %{"nickname" => actor_nickname}, @@ -644,7 +538,6 @@ def get_log_entry_message(%ModerationLog{ "@#{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}, @@ -655,6 +548,16 @@ def get_log_entry_message(%ModerationLog{ "@#{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}") @@ -666,4 +569,16 @@ defp users_to_nicknames_string(users) 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/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 @@ def unread_notifications_count(%User{id: user_id}) do move pleroma:chat_mention pleroma:emoji_reaction + pleroma:report reblog } @@ -367,7 +368,7 @@ def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = act 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 @@ defp type_from_activity(%{data: %{"type" => type}} = activity) do "EmojiReact" -> "pleroma:emoji_reaction" + "Flag" -> + "pleroma:report" + # Compatibility with old reactions "EmojiReaction" -> "pleroma:emoji_reaction" @@ -467,7 +471,7 @@ def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) 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 @@ def get_potential_receiver_ap_ids(%{data: %{"type" => "Follow", "object" => obje [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/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 @@ def authorize_access(%Object{}, %User{}), do: :ok 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 @@ def prune(%Object{data: %{"id" => _id}} = 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/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 169298b34..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 @@ defp maybe_date_fetch(headers, date) 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 @@ def fetch_and_contain_remote_object_from_id(id, opts) when is_binary(id) 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 = @@ -232,8 +219,24 @@ defp get_object_http(id) 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"} 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 @@ def used_changeset(struct) 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 @@ def reset_password(token, data) 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/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 @@ def call(conn = %{method: method}, url, opts) when method in @methods 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 @@ defp track_failed_url(url, error, opts) 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/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 @@ def key_id_to_actor_id(key_id) 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 @@ def fetch_public_key(conn) 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/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 @@ def start_link(_) 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 @@ def force_update 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 @@ def get_status_visibility_count(instance \\ nil) 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 @@ def handle_call(:get_state, _from, state) 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() diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 120034db4..7b26ac7a3 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -82,6 +82,8 @@ defmodule Pleroma.User do ] ] + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + schema "users" do field(:bio, :string, default: "") field(:raw_bio, :string) @@ -129,7 +131,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) @@ -137,7 +138,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) @@ -247,6 +248,18 @@ def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \ 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 @@ -427,7 +440,6 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do params, [ :bio, - :name, :emoji, :ap_id, :inbox, @@ -449,19 +461,33 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do :follower_count, :fields, :following_count, - :discoverable, + :is_discoverable, :invisible, :actor_type, :also_known_as, :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) |> validate_length(:name, max: name_limit) |> 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 @@ -497,7 +523,7 @@ def update_changeset(struct, params \\ %{}) do :fields, :raw_fields, :pleroma_settings_store, - :discoverable, + :is_discoverable, :actor_type, :accepts_chat_messages ] @@ -767,6 +793,16 @@ defp autofollow_users(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,17 +810,50 @@ def register(%Ecto.Changeset{} = changeset) do end end - def post_register_action(%User{} = user) do + def post_register_action(%User{confirmation_pending: true} = user) do + with {:ok, _} <- try_send_confirmation_email(user) do + {:ok, user} + end + end + + def post_register_action(%User{approval_pending: true} = user) do + 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, _} <- autofollowing_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 + defp send_user_approval_email(user) do + user + |> Pleroma.Emails.UserEmail.approval_pending_email() + |> Pleroma.Emails.Mailer.deliver_async() + + {: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 -> + superuser + |> Pleroma.Emails.AdminEmail.new_unapproved_registration(user) + |> Pleroma.Emails.Mailer.deliver_async() + end) + + {:ok, :enqueued} + end + def send_welcome_message(user) do if User.WelcomeMessage.enabled?() do User.WelcomeMessage.post_message(user) @@ -861,7 +930,7 @@ def maybe_direct_follow(%User{} = follower, %User{} = followed) do if not ap_enabled?(followed) do follow(follower, followed) else - {:ok, follower} + {:ok, follower, followed} end end @@ -887,11 +956,6 @@ def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do true -> FollowingRelationship.follow(follower, followed, state) - - {:ok, _} = update_follower_count(followed) - - follower - |> update_following_count() end end @@ -915,11 +979,6 @@ defp do_unfollow(%User{} = follower, %User{} = followed) 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!"} @@ -993,9 +1052,9 @@ def set_cache({:ok, user}), do: set_cache(user) 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 @@ -1018,24 +1077,26 @@ def get_user_friends_ap_ids(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, "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 @@ -1047,11 +1108,11 @@ def get_cached_by_id(id) 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, ""} @@ -1064,7 +1125,7 @@ def get_cached_by_id(id) 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} @@ -1295,47 +1356,6 @@ def update_following_count(%User{local: true} = 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) @@ -1356,14 +1376,51 @@ def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) 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 + if expires_in > 0 do + Pleroma.Workers.MuteExpireWorker.enqueue( + "unmute_user", + %{"muter_id" => muter.id, "mutee_id" => mutee.id}, + schedule_in: expires_in + ) + end + + @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") + + {: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 + @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}") + {:ok, [user_mute, user_notification_mute]} + 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 @@ -1569,11 +1626,34 @@ def approve(users) when is_list(users) do end) end - def approve(%User{} = user) do - change(user, approval_pending: false) - |> update_and_set_cache() + 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) + {:ok, user} + 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 -> + with {:ok, user} <- confirm(user), do: user + end) + end) + end + + 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) + {:ok, user} + end + end + + def confirm(%User{} = user), do: {:ok, user} + def update_notification_settings(%User{} = user, settings) do user |> cast(%{notification_settings: settings}, []) @@ -1620,7 +1700,7 @@ def purge_user_changeset(user) do pleroma_settings_store: %{}, fields: [], raw_fields: [], - discoverable: false, + is_discoverable: false, also_known_as: [] }) end @@ -1770,12 +1850,12 @@ def html_filter_policy(%User{no_rich_text: true}) 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}} -> @@ -1848,8 +1928,8 @@ def public_key(%{public_key: public_key_pem}) when is_binary(public_key_pem) 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 @@ -2060,18 +2140,6 @@ def touch_last_digest_emailed_at(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 @@ -2343,29 +2411,18 @@ def unblock_domain(user, domain_blocked) 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) - 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]} + with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do + @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") + {:ok, relationship} end end @@ -2407,4 +2464,8 @@ defp validate_also_known_as(changeset) do end end) end + + def get_host(%User{ap_id: ap_id} = _user) do + URI.parse(ap_id).host + 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/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 @@ def perform(:follow_import, %User{} = follower, [_ | _] = identifiers) 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/user/query.ex b/lib/pleroma/user/query.ex index 2440bf890..7ef2a1455 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(), + unconfirmed: boolean(), is_admin: boolean(), is_moderator: boolean(), super_users: boolean(), @@ -55,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() @@ -114,6 +116,10 @@ defp compose_query({:is_admin, bool}, 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 @@ -156,6 +162,10 @@ defp compose_query({:need_approval, _}, query) do where(query, [u], u.approval_pending) end + defp compose_query({:unconfirmed, _}, 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/user/search.ex b/lib/pleroma/user/search.ex index 35a828008..f1761ef03 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -85,7 +85,6 @@ defp search_query(query_string, for_user, following, top_user_ids) do |> base_query(following) |> filter_blocked_user(for_user) |> filter_invisible_users() - |> filter_discoverable_users() |> filter_internal_users() |> filter_blocked_domains(for_user) |> fts_search(query_string) @@ -163,10 +162,6 @@ defp filter_invisible_users(query) do from(q in query, where: q.invisible == false) end - defp filter_discoverable_users(query) do - from(q in query, where: q.discoverable == true) - end - defp filter_internal_users(query) do from(q in query, where: q.actor_type != "Application") 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 @@ def tmp_dir(prefix \\ "") 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.ex b/lib/pleroma/web.ex index 7779826e3..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 @@ defp action(conn, params) 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 @@ -172,7 +173,7 @@ def router 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 diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 8022f0402..5059bff03 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -32,6 +32,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 +87,14 @@ defp increase_replies_count_if_reply(%{ 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), @@ -123,7 +126,9 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when # 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 @@ -332,15 +337,21 @@ defp do_unfollow(follower, followed, activity_id, local) 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 +369,8 @@ def flag( {: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 +380,8 @@ def flag( end) {:ok, activity} + else + {:error, error} -> Repo.rollback(error) end end @@ -791,10 +805,10 @@ defp restrict_replies(query, %{ 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. @@ -827,7 +841,14 @@ defp restrict_muted(query, %{muting_user: %User{} = user} = opts) 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 @@ -930,16 +951,11 @@ defp restrict_muted_reblogs(query, %{muting_user: %User{} = user} = opts) do defp restrict_muted_reblogs(query, _), do: query - defp restrict_instance(query, %{instance: instance}) do - 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: fragment("split_part(actor::text, '/'::text, 3) = ?", ^instance) + ) end defp restrict_instance(query, _), do: query @@ -1232,7 +1248,7 @@ defp object_to_user_data(data) do capabilities = data["capabilities"] || %{} accepts_chat_messages = capabilities["acceptsChatMessages"] data = Transmogrifier.maybe_fix_user_object(data) - discoverable = data["discoverable"] || false + is_discoverable = data["discoverable"] || false invisible = data["invisible"] || false actor_type = data["type"] || "Person" @@ -1258,7 +1274,7 @@ defp object_to_user_data(data) do fields: fields, emoji: emojis, is_locked: is_locked, - discoverable: discoverable, + is_discoverable: is_discoverable, invisible: invisible, avatar: avatar, name: data["name"], @@ -1287,12 +1303,10 @@ defp object_to_user_data(data) 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, %{ @@ -1366,11 +1380,12 @@ def user_data_from_user_object(data) 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 + # 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} @@ -1409,13 +1424,13 @@ def maybe_handle_clashing_nickname(data) 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/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/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 570bcc7e7..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 @@ def user(conn, %{"nickname" => nickname}) 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 @@ def object(conn, _) do else {:public?, false} -> {:error, :not_found} + + {:local?, true} -> + {:error, :not_found} end end @@ -108,7 +112,8 @@ def track_object_fetch(conn, object_id) 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 @@ def activity(conn, _params) do |> render("object.json", object: activity) else {:public?, false} -> {:error, :not_found} + {:local?, true} -> {:error, :not_found} nil -> {:error, :not_found} end end @@ -414,7 +420,7 @@ defp handle_user_activity( 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(%{ @@ -458,7 +464,7 @@ def update_outbox( %{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{"nickname" => nickname} = params ) do - actor = user.ap_id() + actor = user.ap_id params = params @@ -525,19 +531,6 @@ defp ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) 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( diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 298aff6b7..e99f6fd83 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -222,6 +222,9 @@ def announce(actor, object, options \\ []) do actor.ap_id == Relay.ap_id() -> [actor.follower_address] + public? and Visibility.is_local_public?(object) -> + [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/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 5e5361082..02fdee5fc 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -3,7 +3,64 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF do + require Logger + + @behaviour Pleroma.Web.ActivityPub.MRF.PipelineFiltering + + @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: "" + } + + @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 @@ -15,6 +72,7 @@ def filter(policies, %{} = message) 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"] @@ -51,8 +109,6 @@ def subdomain_match?(domains, host) 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 +138,41 @@ def describe(policies) 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, @mrf_config_descriptions, 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.debug( + "#{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 @@ defp maybe_add_expiration(activity) 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 @@ def filter(message), do: {:ok, message} @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 @@ def describe 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/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 @@ def perform(:prefetch, url) 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 @@ def filter( %{"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/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 @@ def filter(message), do: {:ok, message} @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 @@ def filter(%{"type" => "Create", "object" => child_object} = object) 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 @@ def describe 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/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/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 @@ def filter(object), do: {:ok, object} @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 @@ def describe 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 @@ def filter(message), do: {:ok, message} @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..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 @@ -41,4 +41,25 @@ def describe do {:ok, %{mrf_user_allowlist: mrf_user_allowlist}} 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 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 @@ def filter(%{"type" => message_type} = message) 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 diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index bd0a2a8dc..ce8e7341b 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -9,6 +9,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 +34,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/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/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index 6f757f49c..338957db8 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 @@ def validate_announcable(cng) 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.Constants.as_local_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/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 @@ def changeset(struct, data) 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]) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 2db86f116..2715b94d4 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -11,14 +11,22 @@ 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 + @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} -> @@ -34,13 +42,13 @@ def common_pipeline(object, meta) 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 @@ -53,9 +61,9 @@ defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} 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 && local 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)} @@ -63,7 +71,7 @@ defp maybe_federate(%Activity{} = activity, meta) do activity end - Federator.publish(activity) + @federator.publish(activity) {:ok, :federated} else {:ok, :not_federated} diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index 9c3956683..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 @@ def is_representable?(%Activity{} = activity) 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 @@ defp http_publish(inbox, actor, json, params) 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 @@ -242,9 +228,7 @@ def publish(%User{} = actor, %{data: %{"bcc" => bcc}} = activity) 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/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index d421ca7af..55c99ad0c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -24,15 +24,20 @@ 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 + @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: %{ @@ -48,10 +53,9 @@ def handle( %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} @@ -61,6 +65,7 @@ def handle( # - Rejects all existing follow activities for this person # - Updates the follow state # - Dismisses notification + @impl true def handle( %{ data: %{ @@ -87,6 +92,7 @@ def handle( # - Follows if possible # - Sends a notification # - Generates accept or reject if appropriate + @impl true def handle( %{ data: %{ @@ -100,7 +106,7 @@ def handle( ) 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) @@ -128,6 +134,7 @@ def handle( # Tasks this handles: # - Unfollow and block + @impl true def handle( %{data: %{"type" => "Block", "object" => blocked_user, "actor" => blocking_user}} = object, @@ -146,6 +153,7 @@ def handle( # # 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 +172,7 @@ def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, # 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,17 +190,20 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) 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 {: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 - 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 @@ -207,6 +219,7 @@ def handle(%{data: %{"type" => "Create"}} = activity, meta) 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"]) @@ -224,6 +237,7 @@ def handle(%{data: %{"type" => "Announce"}} = object, meta) 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 @@ -234,6 +248,7 @@ def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, met # 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) @@ -250,6 +265,7 @@ def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) 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) || @@ -295,6 +311,7 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, end # Nothing to do + @impl true def handle(object, meta) do {:ok, object, meta} end @@ -306,11 +323,18 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) 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) {: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}} @@ -432,6 +456,7 @@ defp add_notifications(meta, notifications) do |> Keyword.put(:notifications, notifications ++ existing) end + @impl true def handle_after_transaction(meta) do meta |> send_notifications() 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/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index d7dd9fe6b..565d32433 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -40,6 +40,7 @@ def fix_object(object, options \\ []) do |> fix_in_reply_to(options) |> fix_emoji |> fix_tag + |> set_sensitive |> fix_content_map |> fix_addressing |> fix_summary @@ -251,6 +252,7 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm } |> Maps.put_if_present("mediaType", media_type) |> Maps.put_if_present("name", data["name"]) + |> Maps.put_if_present("blurhash", data["blurhash"]) else nil end @@ -313,19 +315,21 @@ def fix_tag(%{"tag" => tag} = object) when is_list(tag) 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 - 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. @@ -927,7 +931,7 @@ def set_conversation(object) do Map.put(object, "conversation", object["context"]) end - def set_sensitive(%{"sensitive" => true} = object) do + def set_sensitive(%{"sensitive" => _} = object) do object end @@ -1004,7 +1008,7 @@ def perform(:user_upgrade, user) 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/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 713b0ca1f..ea1c3a04a 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -175,7 +175,8 @@ def maybe_federate(%Activity{local: true, data: %{"type" => type}} = activity) d 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 <- Visibility.is_local_public?(activity) do Pleroma.Web.Federator.publish(activity) end @@ -701,14 +702,30 @@ def make_flag_data(%{actor: actor, context: context, content: content} = params, 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{data: %{"id" => id}, object: %{data: data}}) do + activity_actor = User.get_by_ap_id(data["actor"]) + + %{ + "type" => "Note", + "id" => id, + "content" => data["content"], + "published" => 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 @@ -719,22 +736,14 @@ defp build_flag_object(act) when is_map(act) or is_binary(act) do case Activity.get_by_ap_id_with_object(id) do %Activity{} = activity -> - activity_actor = User.get_by_ap_id(activity.object.data["actor"]) + build_flag_object(activity) - %{ - "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} + 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 diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index c6dee61db..93c9f436c 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -110,7 +110,8 @@ def render("user.json", %{user: user}) do "endpoints" => endpoints, "attachment" => fields, "tag" => emoji_tags, - "discoverable" => user.discoverable, + # Note: key name is indeed "discoverable" (not an error) + "discoverable" => user.is_discoverable, "capabilities" => capabilities } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 5c349bb7a..2cb5a2bd0 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -17,7 +17,19 @@ def is_public?(%Object{data: data}), do: is_public?(data) 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.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), @@ -44,29 +56,30 @@ def is_direct?(activity) 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 +95,26 @@ def entire_thread_visible_for_user?(%Activity{} = activity, %User{} = user) 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"] || [] @@ -93,6 +126,9 @@ def get_visibility(object) 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/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index bdd3e195d..75525104f 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 @@ -13,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 @@ -28,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, :create_backup] ) plug( @@ -37,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, @@ -54,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} @@ -95,132 +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 = - nicknames - |> Enum.map(&User.get_cached_by_nickname/1) - - users - |> Enum.each(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) @@ -274,69 +134,6 @@ def list_user_chats(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} 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(%{ @@ -363,43 +160,6 @@ def untag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, " 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"] - } - - with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) 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 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 @@ -655,7 +415,7 @@ def reload_emoji(conn, _params) 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"}) @@ -681,25 +441,19 @@ def stats(conn, params) 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.User.Backup.create(user, admin.id) do + ModerationLog.insert_log(%{actor: admin, subject: user, action: "create_backup"}) + + json(conn, "") + end + end + defp page_params(params) do - {get_page(params["page"]), get_page_size(params["page_size"])} - end - - defp get_page(page_string) when is_nil(page_string), do: 1 - - defp get_page(page_string) do - case Integer.parse(page_string) do - {page, _} -> page - :error -> 1 - end - end - - defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size - - defp get_page_size(page_size_string) do - case Integer.parse(page_size_string) do - {page_size, _} -> page_size - :error -> @users_page_size - end + { + fetch_integer_param(params, "page", 1), + fetch_integer_param(params, "page_size", @users_page_size) + } 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..fac3522b8 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/frontend_controller.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.AdminAPI.FrontendController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.Web.Plugs.OAuthScopesPlug + + 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 + with :ok <- Pleroma.Frontend.install(params.name, Map.delete(params, :name)) do + index(conn, %{}) + end + end + + defp installed do + File.ls!(Pleroma.Frontend.dir()) + end +end 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..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 @@ -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 @@ def index(%{assigns: %{user: _}} = conn, params) 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/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex index 86da93893..cc77cbfdf 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 @@ def index(conn, params) 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} @@ -50,10 +50,13 @@ def update(%{assigns: %{user: admin}, body_params: %{reports: reports}} = conn, 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 @@ def update(%{assigns: %{user: admin}, body_params: %{reports: reports}} = conn, 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 @@ def notes_delete(%{assigns: %{user: user}} = conn, %{ 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 }) 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..a2a1c875d --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -0,0 +1,281 @@ +# 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.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 + + 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"], + actor_types: params["actor_types"] + } + |> Map.merge(filters) + + with {:ok, users, count} <- Search.user(search_params) do + json( + conn, + AccountView.render("index.json", + users: users, + count: count, + page_size: page_size + ) + ) + end + end + + @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: %{} + + 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/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 @@ def render("credentials.json", %{user: user, for: for_user}) do :skip_thread_containment, :pleroma_settings_store, :raw_fields, - :discoverable, + :is_discoverable, :actor_type ]) |> Map.merge(%{ 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/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 @@ def render("show.json", %{report: report, user: user, account: account, statuses 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: [] diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 51f0e5ef8..bd3a73c11 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 @@ def statuses_operation do :query, %Schema{type: :array, items: VisibilityScope}, "Exclude visibilities" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include reactions from muted acccounts." ) ] ++ pagination_params(), responses: %{ @@ -262,6 +268,12 @@ def mute_operation 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: %{ @@ -335,6 +347,7 @@ def mutes_operation 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()) } @@ -348,6 +361,7 @@ def blocks_operation 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()) } @@ -616,7 +630,7 @@ defp update_credentials_request 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 }, @@ -728,10 +742,17 @@ defp mute_request 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/api_spec/operations/admin/frontend_operation.ex b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex new file mode 100644 index 000000000..96d4cdee7 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.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), + 400 => Operation.response("Error", "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, nullable: true}, + 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 + }, + ref: %Schema{ + type: :string + }, + file: %Schema{ + type: :string + }, + build_url: %Schema{ + type: :string + }, + build_dir: %Schema{ + type: :string + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 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 @@ def index_operation 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/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 @@ def index_operation 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/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 @@ defp notification_type do "mention", "pleroma:emoji_reaction", "pleroma:chat_mention", + "pleroma:report", "move", "follow_request" ], @@ -206,6 +207,8 @@ defp notification_type 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/api_spec/operations/pleroma_backup_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_backup_operation.ex new file mode 100644 index 000000000..6993794db --- /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" => + "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 + } + } + end +end 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 @@ def create_operation 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 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 @@ def delete_operation 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 @@ def update_operation 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 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/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index d7ebde6f6..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 @@ def index_operation 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 @@ def show_operation 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) @@ -223,7 +237,27 @@ def mute_conversation_operation 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/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex index 775dd795d..67c7ea8f3 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 @@ defp create_request do allOf: [BooleanLike], nullable: true, description: "Receive chat notifications?" + }, + "pleroma:emoji_reaction": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive emoji reaction notifications?" } } } @@ -210,6 +215,16 @@ defp update_request 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?" } } } diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 8e19bace7..95720df9f 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 @@ def public_operation do security: [%{"oAuth" => ["read:statuses"]}], parameters: [ local_param(), + instance_param(), only_media_param(), with_muted_param(), exclude_visibilities_param(), @@ -158,8 +159,17 @@ defp local_param 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") + Operation.parameter(:with_muted, :query, BooleanLike, "Include activities by muted users") end defp exclude_visibilities_param do diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index cf743932c..70437003c 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -129,7 +129,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/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 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 60a50b027..e59254791 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 @@ -45,7 +46,8 @@ def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) {_, {: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 @@ -357,7 +359,7 @@ def public_announce?(object, _) 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 @@ -398,31 +400,13 @@ def check_expiry_date(expiry_str) 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 @@ -453,20 +437,46 @@ def unpin(id, user) 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")} 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/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 @@ def create(user, params) 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 @@ defp expires_at(draft) 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 @@ defp poll(draft) do end defp content(draft) do - {content_html, mentions, tags} = - Utils.make_content_html( - draft.status, - draft.attachments, - draft.params, - draft.visibility - ) + {content_html, mentioned_users, tags} = Utils.make_content_html(draft) + + mentions = + mentioned_users + |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end) + |> Utils.get_addressed_users(draft.params[:to]) %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags} end defp to_and_cc(draft) do - addressed_users = - draft.mentions - |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end) - |> Utils.get_addressed_users(draft.params[:to]) - - {to, cc} = - Utils.get_to_and_cc( - draft.user, - addressed_users, - draft.in_reply_to, - draft.visibility, - draft.in_reply_to_conversation - ) - + {to, cc} = Utils.get_to_and_cc(draft) %__MODULE__{draft | to: to, cc: cc} end @@ -172,19 +187,7 @@ defp object(draft) do emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) object = - Utils.make_note_data( - draft.user.ap_id, - draft.to, - draft.context, - draft.content_html, - draft.attachments, - draft.in_reply_to, - draft.tags, - draft.summary, - draft.cc, - draft.sensitive, - draft.poll - ) + 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..1c74ea787 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,62 @@ def attachments_from_ids_descs(ids, descs_str) 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: 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 - if inReplyTo do - {Enum.uniq([inReplyTo.data["actor"] | to]), cc} + cc = [draft.user.follower_address] + + 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] + def get_to_and_cc(%{visibility: "unlisted"} = draft) do + to = [draft.user.follower_address | draft.mentions] cc = [Pleroma.Constants.as_public()] - 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, []} def get_addressed_users(_, to) when is_list(to) do User.get_ap_ids_by_nicknames(to) @@ -203,30 +199,25 @@ defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: 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 @@ -274,7 +265,7 @@ defp build_attachment_link(_), 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 +276,6 @@ def format_input(text, "text/plain", options) do end).() end - @doc """ - Formatting text as BBCode. - """ def format_input(text, "text/bbcode", options) do text |> String.replace(~r/\r/, "") @@ -297,18 +285,12 @@ def format_input(text, "text/bbcode", options) 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) @@ -317,33 +299,21 @@ def format_input(text, "text/markdown", options) 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/endpoint.ex b/lib/pleroma/web/endpoint.ex index 56562c12f..f26542e88 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -7,8 +7,12 @@ defmodule Pleroma.Web.Endpoint do require Pleroma.Constants + alias Pleroma.Config + socket("/socket", Pleroma.Web.UserSocket) + plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) + plug(Pleroma.Web.Plugs.SetLocalePlug) plug(CORSPlug) plug(Pleroma.Web.Plugs.HTTPSecurityPlug) @@ -86,19 +90,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 +110,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 +120,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 +140,34 @@ defmodule MetricsExporter 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) diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 6f759d559..1ac1319f8 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -37,10 +37,11 @@ def redirector_with_meta(conn, params) do tags = build_tags(conn, params) preloads = preload_data(conn, params) + title = "#{Pleroma.Config.get([:instance, :name])}" response = index_content - |> String.replace("", tags <> preloads) + |> String.replace("", tags <> preloads <> title) conn |> put_resp_content_type("text/html") @@ -54,10 +55,11 @@ def redirector_with_preload(conn, %{"path" => ["pleroma", "admin"]}) do def redirector_with_preload(conn, params) do {:ok, index_content} = File.read(index_file_path()) preloads = preload_data(conn, params) + title = "#{Pleroma.Config.get([:instance, :name])}" response = index_content - |> String.replace("", preloads) + |> String.replace("", preloads <> title) conn |> put_resp_content_type("text/html") 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 -# 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 -# 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 -# 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 -# 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 -# 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 -# 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 -# 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 -# 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 -# 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 diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index 130654145..658d20954 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -15,6 +15,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 +41,12 @@ def incoming_ap_doc(params) 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 diff --git a/lib/jason_types.ex b/lib/pleroma/web/federator/publishing.ex similarity index 59% rename from lib/jason_types.ex rename to lib/pleroma/web/federator/publishing.ex index f1fdc96f4..d6fba8f24 100644 --- a/lib/jason_types.ex +++ b/lib/pleroma/web/federator/publishing.ex @@ -2,8 +2,6 @@ # Copyright ยฉ 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -Postgrex.Types.define( - Pleroma.PostgresTypes, - [] ++ Ecto.Adapters.Postgres.extensions(), - json: Jason -) +defmodule Pleroma.Web.Federator.Publishing do + @callback publish(map()) :: any() +end diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index 1ae03e7e2..30e0a2a55 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -51,7 +51,7 @@ def most_recent_update(activities, user) 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}" @@ -83,7 +83,7 @@ def activity_content(%{"content" => content}) 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/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index 93a8294b7..218cdbdf3 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 + if Config.get!([:instance, :public]) 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 = @@ -36,12 +36,13 @@ def render_feed(conn, %{"tag" => raw_tag} = params) 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 752983c3b..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 @@ -22,12 +23,7 @@ def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname def feed_redirect(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do - with %{halted: false} = conn <- - Pleroma.Web.Plugs.EnsureAuthenticatedPlug.call(conn, - unless_func: &Pleroma.Web.Plugs.FederatingPlug.federating?/1 - ) do - ActivityPubController.call(conn, :user) - end + ActivityPubController.call(conn, :user) end def feed_redirect(conn, %{"nickname" => nickname}) do @@ -36,25 +32,18 @@ def feed_redirect(conn, %{"nickname" => nickname}) 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 - - def render_feed(conn, %{"nickname" => nickname} = params) do + def 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" 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"], @@ -69,7 +58,7 @@ def render_feed(conn, %{"nickname" => nickname} = params) do |> render("user.#{format}", user: user, activities: activities, - feed_config: Pleroma.Config.get([:feed]) + feed_config: Config.get([:feed]) ) end end @@ -81,6 +70,8 @@ def errors(conn, {:error, :not_found}) 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/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 08f92d55f..20279ff45 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.MastodonAPI.AuthController + alias Pleroma.Web.OAuth.Token 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/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index fb1552f21..3951d10ac 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 @@ def create(%{assigns: %{app: app}, body_params: params} = conn, _params) 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, %{ @@ -186,7 +185,6 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p :skip_thread_containment, :allow_following_move, :also_known_as, - :discoverable, :accepts_chat_messages ] |> Enum.reduce(%{}, fn key, acc -> @@ -211,7 +209,10 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p end) |> Maps.put_if_present(:actor_type, params[:actor_type]) |> Maps.put_if_present(:also_known_as, params[:also_known_as]) + # 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: # @@ -294,7 +295,8 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) 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) @@ -396,7 +398,7 @@ def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) d @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}) @@ -444,15 +446,27 @@ def follow_by_uri(%{body_params: %{uri: uri}} = conn, _) 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" - 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" diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index 75b809aab..93d057a79 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -7,10 +7,13 @@ 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) @@ -20,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 - - @doc "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 - |> put_session(:oauth_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 - @doc "Local Mastodon FE callback action" - def login(conn, _) do - with {:ok, app} <- get_or_make_app() do + 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 + + defp redirect_to_oauth_form(conn, _params) do + with {:ok, app} <- local_mastofe_app() do path = o_auth_path(conn, :authorize, response_type: "code", @@ -52,9 +66,16 @@ def login(conn, _) 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" @@ -66,7 +87,7 @@ def password_reset(conn, params) 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"]) @@ -77,9 +98,11 @@ defp local_mastodon_root_path(conn) 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/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index 3dcd1c44f..e26ec7136 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 @@ def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{i 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/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 08d6c1c22..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,15 +121,15 @@ def index(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do render(conn, "index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) end @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}, @@ -160,11 +160,7 @@ def create( 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]) @@ -194,13 +190,14 @@ def create(%{assigns: %{user: _user}, body_params: %{media_ids: _} = params} = c 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} @@ -289,9 +286,9 @@ def unbookmark(%{assigns: %{user: user}} = conn, %{id: id}) 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 diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 7a5c80e01..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 @@ def home(%{assigns: %{user: user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) end @@ -111,6 +112,7 @@ def public(%{assigns: %{user: user}} = conn, params) 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 @@ -118,7 +120,8 @@ def public(%{assigns: %{user: user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) end end @@ -172,7 +175,8 @@ def hashtag(%{assigns: %{user: user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity + as: :activity, + with_muted: Map.get(params, :with_muted, false) ) end end @@ -201,7 +205,8 @@ def list(%{assigns: %{user: user}} = conn, %{list_id: id} = params) 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/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 0e4a6fd3f..948a05a6d 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 @@ defp do_render("show.json", %{user: user} = opts) 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" @@ -261,7 +257,7 @@ defp do_render("show.json", %{user: user} = opts) do sensitive: false, fields: user.raw_fields, pleroma: %{ - discoverable: user.discoverable, + discoverable: user.is_discoverable, actor_type: user.actor_type } }, @@ -389,7 +385,7 @@ defp maybe_put_unread_conversation_count(data, %User{id: user_id} = user, %User{ data |> Kernel.put_in( [:pleroma, :unread_conversation_count], - user.unread_conversation_count + Pleroma.Conversation.Participation.unread_count(user) ) end diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index a91994915..82fcff062 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -33,8 +33,15 @@ def render("participation.json", %{participation: participation, for: user}) 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)) + + # 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)) + else + participation.recipients + end %{ id: participation.id |> to_string(), @@ -43,7 +50,8 @@ def render("participation.json", %{participation: participation, for: user}) do last_status: render(StatusView, "show.json", activity: activity, - direct_conversation_id: participation.id + direct_conversation_id: participation.id, + for: user ) } end 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 @@ def render("show.json", _) 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 @@ def render("show.json", _) 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: %{ 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 @@ def render( "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 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 @@ def render("show.json", %{object: object, multiple: multiple, options: options} 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"]) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 435bcde15..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 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} 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 = @@ -435,7 +431,8 @@ def render("attachment.json", %{attachment: attachment}) do text_url: href, type: type, description: attachment["name"], - pleroma: %{mime_type: media_type} + pleroma: %{mime_type: media_type}, + blurhash: attachment["blurhash"] } end 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/media_proxy/invalidation/http.ex b/lib/pleroma/web/media_proxy/invalidation/http.ex index bb81d8888..0b0cde68c 100644 --- a/lib/pleroma/web/media_proxy/invalidation/http.ex +++ b/lib/pleroma/web/media_proxy/invalidation/http.ex @@ -30,7 +30,7 @@ defp do_purge(method, url, headers, options) do {:ok, %{status: status} = env} when 400 <= status and status < 500 -> {:error, env} - {:error, error} = error -> + {:error, _} = error -> error _ -> diff --git a/lib/pleroma/web/metadata/providers/restrict_indexing.ex b/lib/pleroma/web/metadata/providers/restrict_indexing.ex index a1dcb6e15..a08a04b4a 100644 --- a/lib/pleroma/web/metadata/providers/restrict_indexing.ex +++ b/lib/pleroma/web/metadata/providers/restrict_indexing.ex @@ -6,11 +6,11 @@ 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 - def build_tags(%{user: %{local: true, discoverable: true}}), do: [] + def build_tags(%{user: %{local: true, is_discoverable: true}}), do: [] def build_tags(_) do [ 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 @@ defp add_token(changeset) 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/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 @@ def challenge(conn, %{"mfa_token" => mfa_token} = params) 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 d2f9d1ceb..6e3c7e1a1 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 @@ -79,6 +80,13 @@ defp do_authorize(%Plug.Conn{} = conn, params) 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 @@ -88,6 +96,8 @@ defp do_authorize(%Plug.Conn{} = conn, params) 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, @@ -131,11 +141,13 @@ defp handle_existing_authorization( 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) @@ -248,7 +260,7 @@ def token_exchange( 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 +272,7 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"} {: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 +287,7 @@ def token_exchange( {: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 +310,7 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} 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 +320,12 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} # 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) @@ -361,9 +379,17 @@ defp handle_token_exchange_error(%Plug.Conn{} = conn, _error) 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} <- 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 + AuthHelper.delete_session_token(conn) + else + _ -> conn + end + json(conn, %{}) else _error -> 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 @@ def render("token.json", %{token: token} = opts) 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 @@ def render("token.json", %{token: token} = opts) 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 de37998f2..886117d15 100644 --- a/lib/pleroma/web/o_auth/token.ex +++ b/lib/pleroma/web/o_auth/token.ex @@ -27,6 +27,18 @@ 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 + 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 @@ -75,11 +87,11 @@ defp put_refresh_token(changeset, attrs) 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 @@ -130,6 +142,4 @@ def is_expired?(%__MODULE__{valid_until: valid_until}) do end def is_expired?(_), do: false - - defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600) end diff --git a/lib/pleroma/web/o_status/o_status_controller.ex b/lib/pleroma/web/o_status/o_status_controller.ex index b044260b3..668ae0ea4 100644 --- a/lib/pleroma/web/o_status/o_status_controller.ex +++ b/lib/pleroma/web/o_status/o_status_controller.ex @@ -16,10 +16,6 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Web.Plugs.RateLimiter alias Pleroma.Web.Router - plug(Pleroma.Web.Plugs.EnsureAuthenticatedPlug, - unless_func: &Pleroma.Web.Plugs.FederatingPlug.federating?/1 - ) - plug( RateLimiter, [name: :ap_routes, params: ["uuid"]] when action in [:object, :activity] @@ -37,14 +33,12 @@ def object(%{assigns: %{format: format}} = conn, _params) 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 + redirect(conn, to: "/notice/#{activity.id}") else reason when reason in [{:public?, false}, {:activity, nil}] -> {:error, :not_found} @@ -59,13 +53,11 @@ def activity(%{assigns: %{format: format}} = conn, _params) 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 + redirect(conn, to: "/notice/#{activity.id}") else reason when reason in [{:public?, false}, {:activity, nil}] -> {:error, :not_found} @@ -119,6 +111,7 @@ def notice(%{assigns: %{format: format}} = conn, %{"id" => id}) 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/pleroma_api/controllers/backup_controller.ex b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex new file mode 100644 index 000000000..dd0a2e22f --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.BackupController do + use Pleroma.Web, :controller + + 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]) + 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 = Backup.list(user) + render(conn, "index.json", backups: backups) + end + + def create(%{assigns: %{user: user}} = conn, _params) do + with {:ok, _} <- Backup.create(user) do + backups = Backup.list(user) + render(conn, "index.json", backups: backups) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 6357148d0..bfc0a1f19 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 @@ -80,7 +79,8 @@ def post_chat_message( %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 @@ -120,9 +120,7 @@ def mark_as_read( ) 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 @@ -140,33 +138,37 @@ def messages(%{assigns: %{user: user}} = conn, %{id: id} = params) do end end - def index(%{assigns: %{user: %{id: user_id} = user}} = conn, _params) do - blocked_ap_ids = User.blocked_users_ap_ids(user) + def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do + exclude_users = + User.cached_blocked_users_ap_ids(user) ++ + if params[:with_muted], do: [], else: User.cached_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 + + 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/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 @@ def create(%{body_params: params} = conn, %{name: pack_name}) 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 @@ def update(%{body_params: %{shortcode: shortcode} = params} = conn, %{name: pack |> 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 @@ def delete(conn, %{name: pack_name, shortcode: shortcode}) 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 @@ defp handle_error(conn, {:error, :doesnt_exist}, %{code: emoji_code}) 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..bc4c8d840 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 @@ def show(conn, %{name: name, page: page, page_size: page_size}) 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 @@ def show(conn, %{name: name, page: page, page_size: page_size}) 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 @@ def archive(conn, %{name: name}) 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 @@ def download(%{body_params: %{url: url, name: name} = params} = conn, _) 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 @@ def create(conn, %{name: name}) 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 @@ def delete(conn, %{name: name}) do |> put_status(:bad_request) |> json(%{error: "pack name cannot be empty"}) - {:error, _, _} -> + {:error, error, _} -> + error_message = add_posix_error("Couldn't delete the `#{name}` pack", 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 @@ def update(%{body_params: %{metadata: metadata}} = conn, %{name: name}) 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 @@ def import_from_filesystem(conn, _params) 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 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 @@ def index(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) 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/controllers/instances_controller.ex b/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex new file mode 100644 index 000000000..9e97480df --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/instances_controller.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.InstancesController do + use Pleroma.Web, :controller + + alias Pleroma.Instances + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaInstancesOperation + + def show(conn, _params) do + unreachable = + Instances.get_consistently_unreachable() + |> Map.new(fn {host, date} -> {host, to_string(date)} end) + + json(conn, %{"unreachable" => unreachable}) + 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..af75876aa --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/backup_view.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.BackupView do + use Pleroma.Web, :view + + alias Pleroma.User.Backup + alias Pleroma.Web.CommonAPI.Utils + + def render("show.json", %{backup: %Backup{} = backup}) do + %{ + content_type: backup.content_type, + url: download_url(backup), + 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 + + def download_url(%Backup{file_name: file_name}) do + Pleroma.Web.Endpoint.url() <> "/media/backups/" <> file_name + 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..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 @@ -5,10 +5,13 @@ 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 + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + def render( "show.json", %{ @@ -37,6 +40,7 @@ def render( Pleroma.Web.RichMedia.Helpers.fetch_data_for_object(object) ) } + |> put_idempotency_key() end def render("index.json", opts) do @@ -47,4 +51,13 @@ def render("index.json", opts) 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 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 @@ def render("index.json", %{emoji_reactions: emoji_reactions} = opts) 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) %{ diff --git a/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex b/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex index d7d4e4092..ff851a874 100644 --- a/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex +++ b/lib/pleroma/web/plugs/admin_secret_authentication_plug.ex @@ -5,21 +5,14 @@ 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 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 @@ def call(conn, _) 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 @@ def authenticate(%{params: %{"admin_token" => admin_token}} = conn) do end end - def authenticate(conn) do + defp authenticate(conn) do token = secret_token() case get_req_header(conn, "x-admin-token") do @@ -48,10 +41,17 @@ def authenticate(conn) 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}) - |> 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 @@ def maybe_update_password(%User{password_hash: "$6" <> _} = user, password) 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/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 @@ def index(conn, _params) do @defaults %{ttl: nil, query_params: true} + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @impl true def init([]), do: @defaults @@ -53,7 +55,7 @@ def init(opts) 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 @@ defp cache_resp(conn, opts) 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/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 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 70d3091f0..000000000 --- a/lib/pleroma/web/plugs/ensure_user_key_plug.ex +++ /dev/null @@ -1,18 +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 - - def init(opts) do - opts - end - - def call(%{assigns: %{user: _}} = conn, _), do: conn - - def call(conn, _) do - conn - |> assign(: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/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 @@ def init(opts) 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 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 @@ def call(%{method: method} = conn, _) when method in ["POST", "PUT", "PATCH"] 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 @@ defp cache_resposnse(conn, key) 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/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/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,6 +13,47 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do def init(options), do: options + 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 + conn + |> assign(:user, user) + |> AuthHelper.skip_oauth() + else + {:user_match, false} -> + Logger.debug("Failed to map identity from signature (payload actor mismatch)") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{inspect(actor)}") + assign(conn, :valid_signature, false) + + # remove me once testsuite uses mapped capabilities instead of what we do now + {:user, nil} -> + Logger.debug("Failed to map identity from signature (lookup failure)") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{actor}") + conn + end + end + + # no payload, probably a signed fetch + def call(%{assigns: %{valid_signature: true}} = conn, _opts) do + with %User{} = user <- user_from_key_id(conn) do + conn + |> assign(:user, user) + |> AuthHelper.skip_oauth() + else + _ -> + Logger.debug("Failed to map identity from signature (no payload actor mismatch)") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}") + assign(conn, :valid_signature, false) + end + end + + # 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 @@ -31,41 +73,4 @@ defp user_from_key_id(conn) do nil end end - - def call(%{assigns: %{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) - else - {:user_match, false} -> - Logger.debug("Failed to map identity from signature (payload actor mismatch)") - Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{inspect(actor)}") - assign(conn, :valid_signature, false) - - # remove me once testsuite uses mapped capabilities instead of what we do now - {:user, nil} -> - Logger.debug("Failed to map identity from signature (lookup failure)") - Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{actor}") - conn - end - end - - # 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) - else - _ -> - Logger.debug("Failed to map identity from signature (no payload actor mismatch)") - Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}") - assign(conn, :valid_signature, false) - end - end - - # no signature at all - def call(conn, _opts), do: conn end diff --git a/lib/pleroma/web/plugs/o_auth_plug.ex b/lib/pleroma/web/plugs/o_auth_plug.ex index c7b58d90f..eb287318b 100644 --- a/lib/pleroma/web/plugs/o_auth_plug.ex +++ b/lib/pleroma/web/plugs/o_auth_plug.ex @@ -3,9 +3,12 @@ # 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 + alias Pleroma.Helpers.AuthHelper alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.OAuth.App @@ -17,45 +20,26 @@ def init(options), do: options 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 +54,6 @@ defp fetch_user_and_token(token) 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 +69,23 @@ defp fetch_app_and_token(token) 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 +94,14 @@ defp fetch_token_str([token | tail]) 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 AuthHelper.get_session_token(conn) do + nil -> :no_token_found + token -> {:ok, token} + 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 @@ def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) 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 @@ def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) 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/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 @@ def inspect_bucket(conn, bucket_name_root, plug_opts) 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 @@ defp check_rate(action_settings) 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/plugs/session_authentication_plug.ex b/lib/pleroma/web/plugs/session_authentication_plug.ex deleted file mode 100644 index 6e176d553..000000000 --- a/lib/pleroma/web/plugs/session_authentication_plug.ex +++ /dev/null @@ -1,21 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright ยฉ 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Plugs.SessionAuthenticationPlug do - import Plug.Conn - - def init(options) do - options - end - - 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) - 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 e520159e4..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,16 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.SetUserSessionIdPlug do - import Plug.Conn - alias Pleroma.User + alias Pleroma.Helpers.AuthHelper + alias Pleroma.Web.OAuth.Token def init(opts) do opts end - def call(%{assigns: %{user: %User{id: id}}} = conn, _) do - conn - |> put_session(:user_id, id) + def call(%{assigns: %{token: %Token{} = oauth_token}} = conn, _) do + AuthHelper.put_session_token(conn, 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..4f1b163bd 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 @@ -11,9 +11,10 @@ def init(options) do end def call(%{assigns: %{user: %User{} = user}} = conn, _) do - case User.account_status(user) do - :active -> conn - _ -> assign(conn, :user, nil) + if User.account_status(user) == :active do + conn + else + AuthHelper.drop_auth_info(conn) end end 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/push/impl.ex b/lib/pleroma/web/push/impl.ex index da535aa68..82152dffa 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 @@ def format_body( "@#{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} reacted with #{content}" + end + def format_body( %{activity: %{data: %{"type" => type}}} = notification, actor, @@ -179,6 +188,7 @@ def format_title(%{type: type}, mastodon_type) 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 diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 5b5aa0d59..749a573ba 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -25,7 +25,8 @@ defmodule Pleroma.Web.Push.Subscription do timestamps() end - @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention]a + # 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 alerts = Map.take(alerts, @supported_alert_types) 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/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 @@ def fetch_data_for_activity(%Activity{data: %{"type" => "Create"}} = activity) d 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/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 @@ def parse(url) 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 @@ defp set_error_ttl(_url, {:content_type, _}), do: :ok 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 @@ def set_ttl_based_on_image(data, url) 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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d2d939989..aefc9f0be 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -5,6 +5,26 @@ defmodule Pleroma.Web.Router do use Pleroma.Web, :router + pipeline :accepts_html 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 + pipeline :browser do plug(:accepts, ["html"]) plug(:fetch_session) @@ -14,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.EnsureUserTokenAssignsPlug) end pipeline :expect_authentication do @@ -28,15 +49,13 @@ 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.LegacyAuthenticationPlug) plug(Pleroma.Web.Plugs.AuthenticationPlug) end 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 @@ -80,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 @@ -129,16 +148,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 +171,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 +194,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) @@ -223,6 +242,11 @@ 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/install", FrontendController, :install) + + post("/backups", AdminAPIController, :create_backup) end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do @@ -267,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 @@ -296,20 +319,26 @@ defmodule Pleroma.Web.Router do end scope "/oauth", Pleroma.Web.OAuth do - scope [] do - pipe_through(:oauth) - get("/authorize", OAuthController, :authorize) - 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(:oauth) + + get("/authorize", OAuthController, :authorize) + post("/authorize", OAuthController, :create_authorization) + end + + scope [] do + pipe_through(:fetch_session) + + post("/token", OAuthController, :token_exchange) + post("/revoke", OAuthController, :token_revoke) + post("/mfa/challenge", MFAController, :challenge) + end + scope [] do pipe_through(:browser) @@ -353,6 +382,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 @@ -373,6 +405,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 @@ -566,30 +599,43 @@ defmodule Pleroma.Web.Router do ) end - pipeline :ostatus do - plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"]) - plug(Pleroma.Web.Plugs.StaticFEPlug) - end - - pipeline :oembed do - plug(:accepts, ["json", "xml"]) - end - scope "/", Pleroma.Web do - pipe_through([:ostatus, :http_signature]) + # Note: html format is supported only if static FE is enabled + # Note: http signature is only considered for json requests (no auth for non-json requests) + pipe_through([:accepts_html_json, :http_signature, Pleroma.Web.Plugs.StaticFEPlug]) 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 + + scope "/", Pleroma.Web do + # Note: html format is supported only if static FE is enabled + # Note: http signature is only considered for json requests (no auth for non-json requests) + pipe_through([:accepts_html_xml_json, :http_signature, Pleroma.Web.Plugs.StaticFEPlug]) + + # 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([:accepts_html_xml, Pleroma.Web.Plugs.StaticFEPlug]) 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(: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 687b17df6..bdec0897a 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -17,12 +17,96 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do plug(:put_view, Pleroma.Web.StaticFE.StaticFEView) plug(:assign_id) - plug(Pleroma.Web.Plugs.EnsureAuthenticatedPlug, - unless_func: &Pleroma.Web.Plugs.FederatingPlug.federating?/1 - ) - @page_keys ["max_id", "min_id", "limit", "since_id", "order"] + @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), + 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}) + + timeline = + activity.object.data["context"] + |> ActivityPub.fetch_activities_for_context(%{}) + |> Enum.reverse() + |> Enum.map(&represent(&1, &1.object.id == activity.object.id)) + + render(conn, "conversation.html", %{activities: timeline, meta: meta}) + else + %Activity{object: %Object{data: data}} -> + conn + |> put_status(:found) + |> redirect(external: data["url"] || data["external_url"] || data["id"]) + + _ -> + not_found(conn, "Post not found.") + end + end + + 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)}, + {_, :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 + end + + def show(%{assigns: %{object_id: _}} = conn, _params) do + url = Helpers.url(conn) <> conn.request_path + + case Activity.get_create_by_object_ap_id_with_object(url) do + %Activity{} = activity -> + to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) + redirect(conn, to: to) + + _ -> + not_found(conn, "Post not found.") + end + end + + def show(%{assigns: %{activity_id: _}} = conn, _params) do + url = Helpers.url(conn) <> conn.request_path + + case Activity.get_by_ap_id(url) do + %Activity{} = activity -> + to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) + redirect(conn, to: to) + + _ -> + not_found(conn, "Post not found.") + end + end + defp get_title(%Object{data: %{"name" => name}}) when is_binary(name), do: name @@ -81,91 +165,6 @@ defp represent(%Activity{object: %Object{data: data}} = activity, selected) do } end - def show(%{assigns: %{notice_id: notice_id}} = conn, _params) do - with %Activity{local: true} = activity <- - Activity.get_by_id_with_object(notice_id), - true <- Visibility.is_public?(activity.object), - %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}) - - timeline = - activity.object.data["context"] - |> ActivityPub.fetch_activities_for_context(%{}) - |> Enum.reverse() - |> Enum.map(&represent(&1, &1.object.id == activity.object.id)) - - render(conn, "conversation.html", %{activities: timeline, meta: meta}) - else - %Activity{object: %Object{data: data}} -> - conn - |> put_status(:found) - |> redirect(external: data["url"] || data["external_url"] || data["id"]) - - _ -> - not_found(conn, "Post not found.") - end - end - - 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(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 - }) - - _ -> - not_found(conn, "User not found.") - end - end - - def show(%{assigns: %{object_id: _}} = conn, _params) do - url = Helpers.url(conn) <> conn.request_path - - case Activity.get_create_by_object_ap_id_with_object(url) do - %Activity{} = activity -> - to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) - redirect(conn, to: to) - - _ -> - not_found(conn, "Post not found.") - end - end - - def show(%{assigns: %{activity_id: _}} = conn, _params) do - url = Helpers.url(conn) <> conn.request_path - - case Activity.get_by_ap_id(url) do - %Activity{} = activity -> - to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) - redirect(conn, to: to) - - _ -> - not_found(conn, "Post not found.") - end - end - defp assign_id(%{path_info: ["notice", notice_id]} = conn, _opts), do: assign(conn, :notice_id, notice_id) diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index d618dfe54..7d4a1304a 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -36,9 +36,8 @@ def registry, do: @registry ) :: {: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 @@ -57,14 +56,23 @@ def get_topic("hashtag", _user, _oauth_token, %{"tag" => tag} = _params) 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, %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 @@ -88,10 +96,9 @@ def get_topic(stream, _user, _oauth_token, _params) when stream in @user_streams 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} @@ -128,16 +135,10 @@ def remove_socket(topic) 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) @@ -151,8 +152,7 @@ def filtered_by_user?(%User{} = user, %Activity{} = item, streamed_type) 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" && @@ -186,6 +186,19 @@ defp do_stream("direct", item) do end) 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 + 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/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 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 @@ <%= if @data["summary"] do %> - <%= @data["summary"] %> + <%= escape(@data["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 @@ <%= activity_context(@activity) %> <%= if @data["summary"] do %> - <%= @data["summary"] %> + <%= escape(@data["summary"]) %> <% end %> <%= if @activity.local do %> diff --git a/lib/pleroma/web/templates/layout/app.html.eex b/lib/pleroma/web/templates/layout/app.html.eex index 51603fe0c..1ede59fd8 100644 --- a/lib/pleroma/web/templates/layout/app.html.eex +++ b/lib/pleroma/web/templates/layout/app.html.eex @@ -1,234 +1,20 @@ - + - - - - <%= Pleroma.Config.get([:instance, :name]) %> - - + + + <%= Pleroma.Config.get([:instance, :name]) %> + +
    -

    <%= 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 %>
    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 %> - diff --git a/lib/pleroma/web/twitter_api/controller.ex b/lib/pleroma/web/twitter_api/controller.ex index f42dba442..16f43863c 100644 --- a/lib/pleroma/web/twitter_api/controller.ex +++ b/lib/pleroma/web/twitter_api/controller.ex @@ -31,10 +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 + {:ok, _} <- User.confirm(user) do redirect(conn, to: "/") 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, 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 @@ defp create_user(params, opts) do case User.register(changeset) do {:ok, user} -> - maybe_notify_admins(user) {:ok, user} {:error, changeset} -> @@ -58,18 +57,6 @@ defp create_user(params, opts) 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) <- diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 476a33245..4fc14166d 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -74,6 +74,28 @@ def render("chat_update.json", %{chat_message_reference: cm_ref}) do |> Jason.encode!() end + def render("follow_relationships_update.json", item) do + %{ + event: "pleroma:follow_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", diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index 2de945ae5..7c009388a 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -123,6 +123,9 @@ defp webfinger_from_json(doc) 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 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 @@ def perform(%Job{args: %{"op" => op, "user_id" => user_id, "identifiers" => iden {: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 diff --git a/lib/pleroma/workers/backup_worker.ex b/lib/pleroma/workers/backup_worker.ex new file mode 100644 index 000000000..5b4985983 --- /dev/null +++ b/lib/pleroma/workers/backup_worker.ex @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# 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.User.Backup + + def process(backup, admin_user_id \\ nil) do + %{"op" => "process", "backup_id" => backup.id, "admin_user_id" => admin_user_id} + |> new() + |> Oban.insert() + end + + def schedule_deletion(backup) do + days = Pleroma.Config.get([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 + + 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(admin_user_id) + |> Pleroma.Emails.Mailer.deliver() 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 diff --git a/lib/pleroma/workers/mute_expire_worker.ex b/lib/pleroma/workers/mute_expire_worker.ex new file mode 100644 index 000000000..32a12ba85 --- /dev/null +++ b/lib/pleroma/workers/mute_expire_worker.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.MuteExpireWorker do + use Pleroma.Workers.WorkerHelper, queue: "mute_expire" + + @impl Oban.Worker + def perform(%Job{args: %{"op" => "unmute_user", "muter_id" => muter_id, "mutee_id" => mutee_id}}) do + 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 + Pleroma.Web.CommonAPI.remove_mute(user_id, activity_id) + :ok + end +end diff --git a/mix.exs b/mix.exs index 427329d38..f26a5391a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.1.50"), + version: version("2.2.50"), elixir: "~> 1.9", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), @@ -22,7 +22,7 @@ def project do docs: [ source_url_pattern: "https://git.pleroma.social/pleroma/pleroma/blob/develop/%{path}#L%{line}", - logo: "priv/static/static/logo.png", + logo: "priv/static/images/logo.png", extras: ["README.md", "CHANGELOG.md"] ++ Path.wildcard("docs/**/*.md"), groups_for_extras: [ "Installation manuals": Path.wildcard("docs/installation/*.md"), @@ -37,7 +37,8 @@ def project do pleroma: [ include_executables_for: [:unix], applications: [ex_syslogger: :load, syslog: :load, eldap: :transient], - steps: [:assemble, &put_otp_version/1, ©_files/1, ©_nginx_config/1] + steps: [:assemble, &put_otp_version/1, ©_files/1, ©_nginx_config/1], + config_providers: [{Pleroma.Config.ReleaseRuntimeProvider, nil}] ] ] ] @@ -114,10 +115,10 @@ defp oauth_deps do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.4.17"}, + {:phoenix, "~> 1.5.5"}, {:tzdata, "~> 1.0.3"}, {:plug_cowboy, "~> 2.3"}, - {:phoenix_pubsub, "~> 1.1"}, + {:phoenix_pubsub, "~> 2.0"}, {:phoenix_ecto, "~> 4.0"}, {:ecto_enum, "~> 1.4"}, {:ecto_sql, "~> 3.4.4"}, @@ -133,24 +134,21 @@ defp deps do {:calendar, "~> 1.0"}, {:cachex, "~> 3.2"}, {:poison, "~> 3.0", override: true}, - {:tesla, - git: "https://github.com/teamon/tesla/", - ref: "9f7261ca49f9f901ceb73b60219ad6f8a9f6aa30", - override: true}, + {:tesla, "~> 1.4.0", override: true}, {:castore, "~> 0.1"}, {:cowlib, "~> 2.9", override: true}, {:gun, github: "ninenines/gun", ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", override: true}, {:jason, "~> 1.2"}, {:mogrify, "~> 0.7.4"}, - {:ex_aws, "~> 2.1"}, + {:ex_aws, "~> 2.1.6"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, {:earmark, "1.4.3"}, {:bbcode_pleroma, "~> 0.2.0"}, {:crypt, - git: "https://github.com/msantos/crypt.git", - ref: "f63a705f92c26955977ee62a313012e309a4d77a"}, + git: "https://git.pleroma.social/pleroma/elixir-libraries/crypt.git", + ref: "cf2aa3f11632e8b0634810a15b3e612c7526f6a3"}, {:cors_plug, "~> 2.0"}, {:web_push_encryption, "~> 0.3"}, {:swoosh, "~> 1.0"}, @@ -160,14 +158,21 @@ defp deps do {:floki, "~> 0.27"}, {:timex, "~> 3.6"}, {:ueberauth, "~> 0.4"}, - {:linkify, "~> 0.2.0"}, + {:linkify, "~> 0.4.1"}, {:http_signatures, "~> 0.1.0"}, {:telemetry, "~> 0.3"}, {:poolboy, "~> 1.5"}, {:prometheus, "~> 4.6"}, - {:prometheus_ex, "~> 3.0"}, + {:prometheus_ex, + git: "https://git.pleroma.social/pleroma/elixir-libraries/prometheus.ex.git", + ref: "a4e9beb3c1c479d14b352fd9d6dd7b1f6d7deee5", + override: true}, {:prometheus_plugs, "~> 1.1"}, {:prometheus_phoenix, "~> 1.3"}, + # Note: once `prometheus_phx` is integrated into `prometheus_phoenix`, remove the former: + {:prometheus_phx, + git: "https://git.pleroma.social/pleroma/elixir-libraries/prometheus-phx.git", + branch: "no-logging"}, {:prometheus_ecto, "~> 1.4"}, {:recon, "~> 2.5"}, {:quack, "~> 0.1.1"}, @@ -189,7 +194,8 @@ defp deps do ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"}, {:restarter, path: "./restarter"}, {:majic, - git: "https://git.pleroma.social/pleroma/elixir-libraries/majic", branch: "develop"}, + git: "https://git.pleroma.social/pleroma/elixir-libraries/majic.git", + ref: "4c692e544b28d1f5e543fb8a44be090f8cd96f80"}, {:open_api_spex, git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"}, @@ -201,8 +207,11 @@ defp deps do {:mock, "~> 0.3.5", only: :test}, # temporary downgrade for excoveralls, hackney until hackney max_connections bug will be fixed {:excoveralls, "0.12.3", only: :test}, - {:hackney, "1.15.2", override: true}, - {:mox, "~> 0.5", only: :test}, + {:hackney, + git: "https://git.pleroma.social/pleroma/elixir-libraries/hackney.git", + ref: "7d7119f0651515d6d7669c78393fd90950a3ec6e", + override: true}, + {:mox, "~> 1.0", only: :test}, {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test} ] ++ oauth_deps() end diff --git a/mix.lock b/mix.lock index 1f2885440..01caf319e 100644 --- a/mix.lock +++ b/mix.lock @@ -11,17 +11,18 @@ "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e0f16822d578866e186a0974d65ad58cddc1e2ab", [ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"]}, "castore": {:hex, :castore, "0.1.7", "1ca19eee705cde48c9e809e37fdd0730510752cc397745e550f6065a56a701e9", [:mix], [], "hexpm", "a2ae2c13d40e9c308387f1aceb14786dca019ebc2a11484fb2a9f797ea0aa0d8"}, - "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "certifi": {:git, "https://github.com/certifi/erlang-certifi", "e08b12e8993502240c25b78563993776f87ecd2a", [tag: "2.5.1"]}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", "d81be41024569330f296fc472e24198d7499ba78", [ref: "d81be41024569330f296fc472e24198d7499ba78"]}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cors_plug": {:hex, :cors_plug, "2.0.2", "2b46083af45e4bc79632bd951550509395935d3e7973275b2b743bd63cc942ce", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f0d0e13f71c51fd4ef8b2c7e051388e4dfb267522a83a22392c856de7e46465f"}, "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.0", "69fdb5cf92df6373e15675eb4018cf629f5d8e35e74841bb637d6596cb797bbc", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "42868c229d9a2900a1501c5d0355bfd46e24c862c322b0b4f5a6f14fe0216753"}, "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, - "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, + "credo": {:hex, :credo, "1.4.1", "16392f1edd2cdb1de9fe4004f5ab0ae612c92e230433968eab00aafd976282fc", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "155f8a2989ad77504de5d8291fa0d41320fdcaa6a1030472e9967f285f8c7692"}, "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, - "crypt": {:git, "https://github.com/msantos/crypt.git", "f63a705f92c26955977ee62a313012e309a4d77a", [ref: "f63a705f92c26955977ee62a313012e309a4d77a"]}, + "crypt": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/crypt.git", "cf2aa3f11632e8b0634810a15b3e612c7526f6a3", [ref: "cf2aa3f11632e8b0634810a15b3e612c7526f6a3"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, @@ -32,11 +33,11 @@ "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, "eimp": {:hex, :eimp, "1.0.14", "fc297f0c7e2700457a95a60c7010a5f1dcb768a083b6d53f49cd94ab95a28f22", [:rebar3], [{:p1_utils, "1.0.18", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "501133f3112079b92d9e22da8b88bf4f0e13d4d67ae9c15c42c30bd25ceb83b6"}, - "elixir_make": {:hex, :elixir_make, "0.6.1", "8faa29a5597faba999aeeb72bbb9c91694ef8068f0131192fb199f98d32994ef", [:mix], [], "hexpm", "35d33270680f8d839a4003c3e9f43afb595310a592405a00afc12de4c7f55a18"}, + "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, - "ex_aws": {:hex, :ex_aws, "2.1.3", "26b6f036f0127548706aade4a509978fc7c26bd5334b004fba9bfe2687a525df", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0bdbe2aed9f326922fc5a6a80417e32f0c895f4b3b2b0b9676ebf23dd16c5da4"}, + "ex_aws": {:hex, :ex_aws, "2.1.6", "41ab8b4caa48035c96d07faa035d2d9de6df480e7e084c054e662ac888dcd4d4", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a541bd042c1ee26412bb1e749ddf2a1c327e4fb7e382b1cd227e1b00eed3d469"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, "ex_doc": {:hex, :ex_doc, "0.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"}, @@ -52,30 +53,30 @@ "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.18.0", "406d6b9e0e3278162c2ae1de0a60270452c553536772167e2d701f028116f870", [:mix], [], "hexpm", "c3f850be6367ebe1a08616c2158affe4a23231c70391050bf359d5f92f66a571"}, "gun": {:git, "https://github.com/ninenines/gun.git", "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", [ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327"]}, - "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, + "hackney": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/hackney.git", "7d7119f0651515d6d7669c78393fd90950a3ec6e", [ref: "7d7119f0651515d6d7669c78393fd90950a3ec6e"]}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:hex, :http_signatures, "0.1.0", "4e4b501a936dbf4cb5222597038a89ea10781776770d2e185849fa829686b34c", [:mix], [], "hexpm", "f8a7b3731e3fd17d38fa6e343fcad7b03d6874a3b0a108c8568a71ed9c2cf824"}, "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, + "idna": {:git, "https://github.com/benoitc/erlang-idna", "6cff72747821110169ecfac871b0c69e5064afff", [tag: "6.0.0"]}, "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, - "linkify": {:hex, :linkify, "0.2.0", "2518bbbea21d2caa9d372424e1ad845b640c6630e2d016f1bd1f518f9ebcca28", [:mix], [], "hexpm", "b8ca8a68b79e30b7938d6c996085f3db14939f29538a59ca5101988bb7f917f6"}, - "majic": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/majic", "4c692e544b28d1f5e543fb8a44be090f8cd96f80", [branch: "develop"]}, + "linkify": {:hex, :linkify, "0.4.1", "f881eb3429ae88010cf736e6fb3eed406c187bcdd544902ec937496636b7c7b3", [:mix], [], "hexpm", "ce98693f54ae9ace59f2f7a8aed3de2ef311381a8ce7794804bd75484c371dda"}, + "majic": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/majic.git", "4c692e544b28d1f5e543fb8a44be090f8cd96f80", [ref: "4c692e544b28d1f5e543fb8a44be090f8cd96f80"]}, "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "metrics": {:git, "https://github.com/benoitc/erlang-metrics", "c6eb4dcf29f9e907539915e2ab996f40c2ec7e8e", [tag: "1.0.1"]}, "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mimerl": {:git, "https://github.com/benoitc/mimerl", "5a1b22a8fada5b3b40438da00a6923cb87a42bbc", [tag: "1.2.0"]}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, "mogrify": {:hex, :mogrify, "0.7.4", "9b2496dde44b1ce12676f85d7dc531900939e6367bc537c7243a1b089435b32d", [:mix], [], "hexpm", "50d79e337fba6bc95bfbef918058c90f50b17eed9537771e61d4619488f099c3"}, - "mox": {:hex, :mox, "0.5.2", "55a0a5ba9ccc671518d068c8dddd20eeb436909ea79d1799e2209df7eaa98b6c", [:mix], [], "hexpm", "df4310628cd628ee181df93f50ddfd07be3e5ecc30232d3b6aadf30bdfe6092b"}, + "mox": {:hex, :mox, "1.0.0", "4b3c7005173f47ff30641ba044eb0fe67287743eec9bd9545e37f3002b0a9f8b", [:mix], [], "hexpm", "201b0a20b7abdaaab083e9cf97884950f8a30a1350a1da403b3145e213c6f4df"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, "nimble_pool": {:hex, :nimble_pool, "0.1.0", "ffa9d5be27eee2b00b0c634eb649aa27f97b39186fec3c493716c2a33e784ec6", [:mix], [], "hexpm", "343a1eaa620ddcf3430a83f39f2af499fe2370390d4f785cd475b4df5acaf3f9"}, @@ -83,16 +84,16 @@ "oban": {:hex, :oban, "2.1.0", "034144686f7e76a102b5d67731f098d98a9e4a52b07c25ad580a01f83a7f1cf5", [:mix], [{:ecto_sql, ">= 3.4.3", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6f067fa3b308ed9e0e6beb2b34277c9c4e48bf95338edabd8f4a757a26e04c2"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, "p1_utils": {:hex, :p1_utils, "1.0.18", "3fe224de5b2e190d730a3c5da9d6e8540c96484cf4b4692921d1e28f0c32b01c", [:rebar3], [], "hexpm", "1fc8773a71a15553b179c986b22fbeead19b28fe486c332d4929700ffeb71f88"}, - "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, + "parse_trans": {:git, "https://github.com/uwiger/parse_trans.git", "76abb347c3c1d00fb0ccf9e4b43e22b3d2288484", [tag: "3.3.0"]}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, - "phoenix": {:hex, :phoenix, "1.4.17", "1b1bd4cff7cfc87c94deaa7d60dd8c22e04368ab95499483c50640ef3bd838d8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a8e5d7a3d76d452bb5fb86e8b7bd115f737e4f8efe202a463d4aeb4a5809611"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, + "phoenix": {:hex, :phoenix, "1.5.6", "8298cdb4e0f943242ba8410780a6a69cbbe972fef199b341a36898dd751bdd66", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0dc4d39af1306b6aa5122729b0a95ca779e42c708c6fe7abbb3d336d5379e956"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"}, "phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, - "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.3.0", "2acfa0db038a7649e0a4614eee970e6ed9a39d191ccd79a03583b51d0da98165", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.0", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "b8bbae4b59a676de6b8bd8675eda37bc8b4424812ae429d6fdcb2b039e00003b"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, + "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.3.2", "43d3518349a22b8b1910ea28b4dd5119926d5017b3187db3fbd1a1e05769a851", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.0", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "3e2ac4e883db7af0702d75ba00c19901760e8342b91f8f66e13941de552e777f"}, "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.3.0", "149a50e05cb73c12aad6506a371cd75750c0b19a32f81866e1a323dda9e0e99d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bc595a1870cef13f9c1e03df56d96804db7f702175e4ccacdb8fc75c02a7b97e"}, - "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.4.0", "e936ef151751f386804c51f87f7300f5aaae6893cdad726559c3930c6c032948", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e25ddcfc06b1b76e55af79d078b03cbc86bbcb99ce4e5e0a5e4a8114ee039be6"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, @@ -100,25 +101,26 @@ "pot": {:hex, :pot, "0.11.0", "61bad869a94534739dd4614a25a619bc5c47b9970e9a0ea5bef4628036fc7a16", [:rebar3], [], "hexpm", "57ee6ee6bdeb639661ffafb9acefe3c8f966e45394de6a766813bb9e1be4e54b"}, "prometheus": {:hex, :prometheus, "4.6.0", "20510f381db1ccab818b4cf2fac5fa6ab5cc91bc364a154399901c001465f46f", [:mix, :rebar3], [], "hexpm", "4905fd2992f8038eccd7aa0cd22f40637ed618c0bed1f75c05aacec15b7545de"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, - "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, + "prometheus_ex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/prometheus.ex.git", "a4e9beb3c1c479d14b352fd9d6dd7b1f6d7deee5", [ref: "a4e9beb3c1c479d14b352fd9d6dd7b1f6d7deee5"]}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, + "prometheus_phx": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/prometheus-phx.git", "9cd8f248c9381ffedc799905050abce194a97514", [branch: "no-logging"]}, "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm", "0273a6483ccb936d79ca19b0ab629aef0dba958697c94782bb728b920dfc6a79"}, "quack": {:hex, :quack, "0.1.1", "cca7b4da1a233757fdb44b3334fce80c94785b3ad5a602053b7a002b5a8967bf", [:mix], [{:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "d736bfa7444112eb840027bb887832a0e403a4a3437f48028c3b29a2dbbd2543"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, "recon": {:hex, :recon, "2.5.1", "430ffa60685ac1efdfb1fe4c97b8767c92d0d92e6e7c3e8621559ba77598678a", [:mix, :rebar3], [], "hexpm", "5721c6b6d50122d8f68cccac712caa1231f97894bab779eff5ff0f886cb44648"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "ssl_verify_fun": {:git, "https://github.com/deadtrickster/ssl_verify_fun.erl", "c5718226b0b9f3d1a38ef6ca3c3b4c75f53dda92", [tag: "1.1.4"]}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, - "swoosh": {:hex, :swoosh, "1.0.0", "c547cfc83f30e12d5d1fdcb623d7de2c2e29a5becfc68bf8f42ba4d23d2c2756", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "b3b08e463f876cb6167f7168e9ad99a069a724e124bcee61847e0e1ed13f4a0d"}, + "swoosh": {:hex, :swoosh, "1.0.6", "6765e334c67dacabe721f0d701c7e5a6f06e4595c90df6f91e73ebd54d555833", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "7c50ef78e4acfd1cbd4907dc1fa87b5540675a6be9dc979d04890f49d7ec1830"}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, - "tesla": {:git, "https://github.com/teamon/tesla/", "9f7261ca49f9f901ceb73b60219ad6f8a9f6aa30", [ref: "9f7261ca49f9f901ceb73b60219ad6f8a9f6aa30"]}, + "tesla": {:hex, :tesla, "1.4.0", "1081bef0124b8bdec1c3d330bbe91956648fb008cf0d3950a369cda466a31a87", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "bf1374a5569f5fca8e641363b63f7347d680d91388880979a33bc12a6eb3e0aa"}, "timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, - "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, + "tzdata": {:hex, :tzdata, "1.0.4", "a3baa4709ea8dba552dca165af6ae97c624a2d6ac14bd265165eaa8e8af94af6", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "b02637db3df1fd66dd2d3c4f194a81633d0e4b44308d36c1b2fdfd1e4e6f169b"}, "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, + "unicode_util_compat": {:git, "https://github.com/benoitc/unicode_util_compat.git", "38d7bc105f51159e8ea3279c40121db9db1e652f", [tag: "0.3.1"]}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, "web_push_encryption": {:hex, :web_push_encryption, "0.3.0", "598b5135e696fd1404dc8d0d7c0fa2c027244a4e5d5e5a98ba267f14fdeaabc8", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "f10bdd1afe527ede694749fb77a2f22f146a51b054c7fa541c9fd920fba7c875"}, "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, diff --git a/priv/gettext/en/LC_MESSAGES/posix_errors.po b/priv/gettext/en/LC_MESSAGES/posix_errors.po new file mode 100644 index 000000000..4d8fbf1d3 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/posix_errors.po @@ -0,0 +1,141 @@ +## This file is a PO Template file. +msgid "eperm" +msgstr "Operation not permitted" + +msgid "eacces" +msgstr "Permission denied" + +msgid "eagain" +msgstr "Resource temporarily unavailable" + +msgid "ebadf" +msgstr "Bad file descriptor" + +msgid "ebadmsg" +msgstr "Bad message" + +msgid "ebusy" +msgstr "Device or resource busy" + +msgid "edeadlk" +msgstr "Resource deadlock avoided" + +msgid "edeadlock" +msgstr "Resource deadlock avoided" + +msgid "edquot" +msgstr "Disk quota exceeded" + +msgid "eexist" +msgstr "File exists" + +msgid "efault" +msgstr "Bad address" + +msgid "efbig" +msgstr "File is too large" + +msgid "eftype" +msgstr "Inappropriate file type or format" + +msgid "eintr" +msgstr "Interrupted system call" + +msgid "einval" +msgstr "Invalid argument" + +msgid "eio" +msgstr "Input/output error" + +msgid "eisdir" +msgstr "Illegal operation on a directory" + +msgid "eloop" +msgstr "Too many levels of symbolic links" + +msgid "emfile" +msgstr "Too many open files" + +msgid "emlink" +msgstr "Too many links" + +msgid "emultihop" +msgstr "Multihop attempted" + +msgid "enametoolong" +msgstr "File name is too long" + +msgid "enfile" +msgstr "Too many open files in system" + +msgid "enobufs" +msgstr "No buffer space available" + +msgid "enodev" +msgstr "No such device" + +msgid "enolck" +msgstr "No locks available" + +msgid "enolink" +msgstr "Link has been severed" + +msgid "enoent" +msgstr "No such file or directory" + +msgid "enomem" +msgstr "Cannot allocate memory" + +msgid "enospc" +msgstr "No space left on device" + +msgid "enosr" +msgstr "Out of streams resources" + +msgid "enostr" +msgstr "Device is not a stream" + +msgid "enosys" +msgstr "Function not implemented" + +msgid "enotblk" +msgstr "Block device required" + +msgid "enotdir" +msgstr "Not a directory" + +msgid "enotsup" +msgstr "Operation not supported" + +msgid "enxio" +msgstr "No such device or address" + +msgid "eopnotsupp" +msgstr "Operation not supported" + +msgid "eoverflow" +msgstr "Value too large for defined data type" + +msgid "epipe" +msgstr "Broken pipe" + +msgid "erange" +msgstr "Numerical result out of range" + +msgid "erofs" +msgstr "Read-only file system" + +msgid "espipe" +msgstr "Illegal seek" + +msgid "esrch" +msgstr "No such process" + +msgid "estale" +msgstr "Stale file handle" + +msgid "etxtbsy" +msgstr "Text file busy" + +msgid "exdev" +msgstr "Invalid cross-device link" diff --git a/priv/gettext/he/LC_MESSAGES/errors.po b/priv/gettext/he/LC_MESSAGES/errors.po new file mode 100644 index 000000000..7e251383f --- /dev/null +++ b/priv/gettext/he/LC_MESSAGES/errors.po @@ -0,0 +1,599 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-11-10 13:39+0000\n" +"PO-Revision-Date: 2020-11-21 04:42+0000\n" +"Last-Translator: Guy Sheffer \n" +"Language-Team: Hebrew \n" +"Language: he\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && " +"n % 10 == 0) ? 2 : 3));\n" +"X-Generator: Weblate 4.0.4\n" + +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "ืœื ื™ื›ื•ืœ ืœื”ื™ื•ืช ืจื™ืง" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "ื›ื‘ืจ ื ืœืงื—" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "ืื™ื ื• ืชืงื ื™" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "ืชื‘ื ื™ืช ืื™ื ื” ืชืงื ื™ืช" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "ื‘ืขืœ.ื” ืจืฉื•ืžื” ืœื ื—ื•ืงื™ืช" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "ื”ื™ื ื• ืฉืžื•ืจ" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "ืื™ื ื• ืชื•ืจื ืืช ื”ืื™ืžื•ืช" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "ืขื“ื™ื™ืŸ ืžืฉื•ื™ืš ืœืจืฉื•ืžื” ื–ื•" + +msgid "are still associated with this entry" +msgstr "ืขื“ื™ื™ืŸ ืžืฉื•ื™ื›ื™ื ืœืจืฉื•ืžื” ื–ื•" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "ืื—ื“" +msgstr[1] "ืฉื ื™" +msgstr[2] "ื‘ื•ื“ื“ื™ื" +msgstr[3] "ืื—ืจ" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "ืื—ื“" +msgstr[1] "ืฉื ื™" +msgstr[2] "ื‘ื•ื“ื“ื™ื" +msgstr[3] "ืื—ืจ" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "ืื—ื“" +msgstr[1] "ืฉื ื™ื" +msgstr[2] "ื‘ื•ื“ื“ื™ื" +msgstr[3] "ืื—ืจ" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "ืื—ื“" +msgstr[1] "ืฉื ื™ื™ื" +msgstr[2] "ื‘ื•ื“ื“ื™ื" +msgstr[3] "ืื—ืจ" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "ืื—ื“" +msgstr[1] "ืฉื ื™ื™ื" +msgstr[2] "ื‘ื•ื“ื“ื™ื" +msgstr[3] "ืื—ืจ" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "ืื—ื“" +msgstr[1] "ืฉื ื™ื™ื" +msgstr[2] "ื‘ื•ื“ื“ื™ื" +msgstr[3] "ืื—ืจ" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "ื—ื™ื™ื‘ ืœื”ื™ื•ืช ืžืชื—ืช ืœ-%{number}" + +msgid "must be greater than %{number}" +msgstr "ื—ื™ื™ื‘ ืœื”ื™ื•ืช ืžืขืœ ืœ-%{number}" + +msgid "must be less than or equal to %{number}" +msgstr "ื—ื™ื™ื‘ ืœื”ื™ื•ืช ืฉื•ื•ื” ืœ-%{number}" + +msgid "must be greater than or equal to %{number}" +msgstr "ื—ื™ื™ื‘ ืœื”ื™ื•ืช ื’ื“ื•ืœ ืื• ืฉื•ื•ื” ืœ-%{number}" + +msgid "must be equal to %{number}" +msgstr "ื—ื™ื™ื‘ ืœื”ื™ื•ืช ืฉื•ื•ื” ืœ-%{number}" + +#: lib/pleroma/web/common_api/common_api.ex:505 +#, elixir-format +msgid "Account not found" +msgstr "ื—ืฉื‘ื•ืŸ ืœื ื ืžืฆื" + +#: lib/pleroma/web/common_api/common_api.ex:339 +#, elixir-format +msgid "Already voted" +msgstr "ื”ืฆื‘ืขื” ื›ื‘ืจ ื”ืชื‘ืฆืขื”" + +#: lib/pleroma/web/oauth/oauth_controller.ex:359 +#, elixir-format +msgid "Bad request" +msgstr "ื‘ืงืฉื” ืฉื’ื•ื™ื™ื”" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:426 +#, elixir-format +msgid "Can't delete object" +msgstr "ืœื ื ื™ืชืŸ ืœืžื—ื•ืง ืื•ื‘ื™ื™ืงื˜" + +#: lib/pleroma/web/controller_helper.ex:105 +#: lib/pleroma/web/controller_helper.ex:111 +#, elixir-format +msgid "Can't display this activity" +msgstr "ืœื ื ื™ืชืŸ ืœื”ืฆื™ื’ ืคืขื™ืœื•ืช" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:285 +#, elixir-format +msgid "Can't find user" +msgstr "ืœื ื ื™ืชืŸ ืœืžืฆื•ื ืžืฉืชืžืฉ" + +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:61 +#, elixir-format +msgid "Can't get favorites" +msgstr "ืœื ื ื™ืชืŸ ืœืžืฆื•ื ืžื•ืขื“ืคื™ื" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:438 +#, elixir-format +msgid "Can't like object" +msgstr "ืœื ื ื™ืชืŸ ืœืขืฉื•ืช ืœื—ื‘ื‘ ืื•ื‘ื™ื™ืงื˜" + +#: lib/pleroma/web/common_api/utils.ex:563 +#, elixir-format +msgid "Cannot post an empty status without attachments" +msgstr "ืœื ื ื™ืชืŸ ืœืฉืœื•ื— ืกื˜ื˜ื•ืก ืจื™ืง ืœืœื ืงื‘ืฆื™ื ืžืฆื•ืจืคื™ื" + +#: lib/pleroma/web/common_api/utils.ex:511 +#, elixir-format +msgid "Comment must be up to %{max_size} characters" +msgstr "ืชื’ื•ื‘ื” ื—ื™ื™ื‘ืช ืœื”ื™ื•ืช ืขื“ %{max_size} ืชื•ื•ื™ื" + +#: lib/pleroma/config/config_db.ex:191 +#, elixir-format +msgid "Config with params %{params} not found" +msgstr "ื”ื’ื“ืจื” ืขื ืคืจืžื˜ืจ %{params} ืœื ื ืžืฆืื”" + +#: lib/pleroma/web/common_api/common_api.ex:181 +#: lib/pleroma/web/common_api/common_api.ex:185 +#, elixir-format +msgid "Could not delete" +msgstr "ืœื ื ื™ืชืŸ ืœืžื—ื•ืง" + +#: lib/pleroma/web/common_api/common_api.ex:231 +#, elixir-format +msgid "Could not favorite" +msgstr "ืœื ื ื™ืชืŸ ืœื—ื‘ื‘" + +#: lib/pleroma/web/common_api/common_api.ex:453 +#, elixir-format +msgid "Could not pin" +msgstr "ืœื ื ื™ืชืŸ ืœื ืขื•ืฅ" + +#: lib/pleroma/web/common_api/common_api.ex:278 +#, elixir-format +msgid "Could not unfavorite" +msgstr "ืœื ื ื™ืชืŸ ืœื”ืกื™ืจ ื—ื™ื‘ื•ื‘" + +#: lib/pleroma/web/common_api/common_api.ex:463 +#, elixir-format +msgid "Could not unpin" +msgstr "ืœื ื ื™ืชืŸ ืœื‘ื˜ืœ ื ืขื™ืฆื”" + +#: lib/pleroma/web/common_api/common_api.ex:216 +#, elixir-format +msgid "Could not unrepeat" +msgstr "ืœื ื ื™ืชืŸ ืœื‘ื˜ืœ ื—ื–ืจื”" + +#: lib/pleroma/web/common_api/common_api.ex:512 +#: lib/pleroma/web/common_api/common_api.ex:521 +#, elixir-format +msgid "Could not update state" +msgstr "ืœื ื ื™ืชืŸ ืœืขื“ื›ืŸ ืžืฆื‘" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:207 +#, elixir-format +msgid "Error." +msgstr "ืฉื’ื™ืื”." + +#: lib/pleroma/web/twitter_api/twitter_api.ex:106 +#, elixir-format +msgid "Invalid CAPTCHA" +msgstr "CAPTCHA ืœื ืชืงื™ืŸ" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:568 +#, elixir-format +msgid "Invalid credentials" +msgstr "ื ืชื•ื ื™ ืื™ืžื•ืช ืœื ื ื›ื•ื ื™ื" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 +#, elixir-format +msgid "Invalid credentials." +msgstr "ื ืชื•ื ื™ ืื™ืžื•ืช ืœื ื ื›ื•ื ื™ื." + +#: lib/pleroma/web/common_api/common_api.ex:355 +#, elixir-format +msgid "Invalid indices" +msgstr "ืื™ื ื“ืงืก ืœื ืชืงื™ืŸ" + +#: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid parameters" +msgstr "ืคืจืžื˜ืจื™ื ืœื ืชืงื™ื ื™ื" + +#: lib/pleroma/web/common_api/utils.ex:414 +#, elixir-format +msgid "Invalid password." +msgstr "ืกื™ืกืžื” ืœื ืชืงื™ื ื”." + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:220 +#, elixir-format +msgid "Invalid request" +msgstr "ื‘ืงืฉื” ืœื ืชืงื™ื ื”" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:109 +#, elixir-format +msgid "Kocaptcha service unavailable" +msgstr "ืฉื™ืจื•ืช Kocaptcha ืœื ื–ืžื™ืŸ" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:112 +#, elixir-format +msgid "Missing parameters" +msgstr "ืคืจืžื˜ืจื™ื ื—ืกืจื™ื" + +#: lib/pleroma/web/common_api/utils.ex:547 +#, elixir-format +msgid "No such conversation" +msgstr "ืฉื™ื—ื” ืœื ืงื™ื™ืžืช" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:388 +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:414 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:456 +#, elixir-format +msgid "No such permission_group" +msgstr "permission_group ืœื ืงื™ื™ื" + +#: lib/pleroma/plugs/uploaded_media.ex:84 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:486 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:11 +#: lib/pleroma/web/feed/user_controller.ex:71 lib/pleroma/web/ostatus/ostatus_controller.ex:143 +#, elixir-format +msgid "Not found" +msgstr "ืœื ื ืžืฆื" + +#: lib/pleroma/web/common_api/common_api.ex:331 +#, elixir-format +msgid "Poll's author can't vote" +msgstr "ืžื—ื‘ืจ ื”ืกืงืจ ืœื ื™ื›ื•ืœ.ื” ืœื”ืฆื‘ื™ืข" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:306 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 +#, elixir-format +msgid "Record not found" +msgstr "ืจืฉื•ืžื” ืœื ื ืžืฆืื”" + +#: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:35 +#: lib/pleroma/web/feed/user_controller.ex:77 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:36 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:149 +#, elixir-format +msgid "Something went wrong" +msgstr "ืžืฉื”ื• ื”ืฉืชื‘ืฉ" + +#: lib/pleroma/web/common_api/activity_draft.ex:107 +#, elixir-format +msgid "The message visibility must be direct" +msgstr "ื”ื ืจืื•ืช ืฉืœ ื”ื”ื•ื“ืขื” ื—ื™ื™ื‘ืช ืœื”ื™ื•ืช ื™ืฉื™ืจื”" + +#: lib/pleroma/web/common_api/utils.ex:573 +#, elixir-format +msgid "The status is over the character limit" +msgstr "ื”ืกื˜ื˜ื•ืก ืžืขืœ ืœื”ื’ื‘ืœืช ื”ืชื•ื•ื™ื" + +#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 +#, elixir-format +msgid "This resource requires authentication." +msgstr "ื”ืžืฉืื‘ ื”ื–ื” ื“ื•ืจืฉ ื”ืจืฉืื”." + +#: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 +#, elixir-format +msgid "Throttled" +msgstr "ืžื•ืฉื ืง" + +#: lib/pleroma/web/common_api/common_api.ex:356 +#, elixir-format +msgid "Too many choices" +msgstr "ื™ื•ืชืจ ืžื“ื™ื™ ืืคืฉืจื•ื™ื•ืช" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:443 +#, elixir-format +msgid "Unhandled activity type" +msgstr "ืื™ืŸ ื”ืชืžื•ื“ื“ื•ืช ืœืกื•ื’ ื”ืคืขื™ืœื•ืช" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:485 +#, elixir-format +msgid "You can't revoke your own admin status." +msgstr "ืœื ื ื™ืชืŸ ืœื‘ื˜ืœ ืืช ื”ืจืฉืืช ื”ืžื ื”ืœ ืฉืœ ืขืฆืžืš." + +#: lib/pleroma/web/oauth/oauth_controller.ex:221 +#: lib/pleroma/web/oauth/oauth_controller.ex:308 +#, elixir-format +msgid "Your account is currently disabled" +msgstr "ื”ื—ืฉื‘ื•ืŸ ืฉืœืš ื›ืจื’ืข ืžื‘ื•ื˜ืœ" + +#: lib/pleroma/web/oauth/oauth_controller.ex:183 +#: lib/pleroma/web/oauth/oauth_controller.ex:331 +#, elixir-format +msgid "Your login is missing a confirmed e-mail address" +msgstr "ื—ืกืจ ืœื—ืฉื‘ื•ืŸ ืฉืœืš ื›ืชื•ื‘ืช ื“ื•ืืจ ืืœืงื˜ืจื•ื ื™ ืžืื•ืฉืจ" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:390 +#, elixir-format +msgid "can't read inbox of %{nickname} as %{as_nickname}" +msgstr "ืœื ื ื™ืชืŸ ืœืงืจื•ื ืืช ื”ื“ื•ืืจ ื”ื ื›ื ืก ืฉืœ %{nickname} ื‘ืชื•ืจ %{as_nickname}" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:473 +#, elixir-format +msgid "can't update outbox of %{nickname} as %{as_nickname}" +msgstr "ืœื ื ื™ืชืŸ ืœืขื“ื›ืŸ ืืช ื—ืฉื‘ื•ืŸ ื”ื“ื•ืืจ ื”ื™ื•ืฆื ืฉืœ %{nickname} ื‘ืชื•ืจ %{as_nickname}" + +#: lib/pleroma/web/common_api/common_api.ex:471 +#, elixir-format +msgid "conversation is already muted" +msgstr "ืฉื™ื—ื” ื›ื‘ืจ ื”ื•ืฉืชืงื”" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:314 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:492 +#, elixir-format +msgid "error" +msgstr "ืฉื’ื™ืื”" + +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:32 +#, elixir-format +msgid "mascots can only be images" +msgstr "ืงืžืข ื™ื›ื•ืœ ืœื”ื™ื•ืช ืจืง ืชืžื•ื ื•ืช" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:62 +#, elixir-format +msgid "not found" +msgstr "ืœื ื ืžืฆื" + +#: lib/pleroma/web/oauth/oauth_controller.ex:394 +#, elixir-format +msgid "Bad OAuth request." +msgstr "ื‘ืงืฉืช OAuth ืฉื’ื•ื™ื™ื”." + +#: lib/pleroma/web/twitter_api/twitter_api.ex:115 +#, elixir-format +msgid "CAPTCHA already used" +msgstr "ื›ื‘ืจ ื ืขืฉื” ืฉื™ืžื•ืฉ ื‘-CAPTCHA ื”ื–ื”" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:112 +#, elixir-format +msgid "CAPTCHA expired" +msgstr "ืคื’ ืชื•ืงืฃ CAPTCHA" + +#: lib/pleroma/plugs/uploaded_media.ex:57 +#, elixir-format +msgid "Failed" +msgstr "ื ื›ืฉืœ" + +#: lib/pleroma/web/oauth/oauth_controller.ex:410 +#, elixir-format +msgid "Failed to authenticate: %{message}." +msgstr "ื ื›ืฉืœ ื”ืื™ืžื•ืช: %{message}." + +#: lib/pleroma/web/oauth/oauth_controller.ex:441 +#, elixir-format +msgid "Failed to set up user account." +msgstr "ื”ื’ื“ืจืช ื—ืฉื‘ื•ืŸ ืžืฉืชืžืฉ ื ื›ืฉืœื”." + +#: lib/pleroma/plugs/oauth_scopes_plug.ex:38 +#, elixir-format +msgid "Insufficient permissions: %{permissions}." +msgstr "ืื™ืŸ ืžืกืคื™ืง ื”ืจืฉืื•ืช: %{permissions}." + +#: lib/pleroma/plugs/uploaded_media.ex:104 +#, elixir-format +msgid "Internal Error" +msgstr "ืฉื’ื™ืื” ืคื ื™ืžื™ืช" + +#: lib/pleroma/web/oauth/fallback_controller.ex:22 +#: lib/pleroma/web/oauth/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid Username/Password" +msgstr "ืฉื ืžืฉืชืžืฉ/ืกื™ืกืžื” ืฉื’ื•ื™ื™ื" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:118 +#, elixir-format +msgid "Invalid answer data" +msgstr "ืชืฉื•ื‘ื” ืฉื’ื•ื™ื™ื” ืœืžื™ื“ืข" + +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:33 +#, elixir-format +msgid "Nodeinfo schema version not handled" +msgstr "Nodeinfo ืฉืœ ืฉืœ ื’ืจืกืช ื”ืกื›ืžื” ืœื ื ื™ืชืŸ ืœื˜ื™ืคื•ืœ" + +#: lib/pleroma/web/oauth/oauth_controller.ex:172 +#, elixir-format +msgid "This action is outside the authorized scopes" +msgstr "ื”ืคืขื•ืœื” ื”ื–ื• ืžื—ื•ืฅ ืœืชื—ื•ืžื™ ื”ื”ืจืฉืื•ืช" + +#: lib/pleroma/web/oauth/fallback_controller.ex:14 +#, elixir-format +msgid "Unknown error, please check the details and try again." +msgstr "ืฉื’ื™ืื” ืœื ื™ื“ื•ืขื”, ื™ืฉ ืœื‘ื“ื•ืง ืืช ืคืจื˜ื™ื ื•ืœื ืกื•ืช ืฉื•ื‘." + +#: lib/pleroma/web/oauth/oauth_controller.ex:119 +#: lib/pleroma/web/oauth/oauth_controller.ex:158 +#, elixir-format +msgid "Unlisted redirect_uri." +msgstr "ื ื™ืชื‘ redirect_uri ืœื ืจืฉื•ื." + +#: lib/pleroma/web/oauth/oauth_controller.ex:390 +#, elixir-format +msgid "Unsupported OAuth provider: %{provider}." +msgstr "ืกืคืง OAuth ืœื ื ืชืžืš: %{provider}." + +#: lib/pleroma/uploaders/uploader.ex:72 +#, elixir-format +msgid "Uploader callback timeout" +msgstr "ืงืจื™ืื” ื—ื–ืจื” ืฉืœ ืžืขืœื” ืขื‘ืจื” ืืช ื”ื–ืžืŸ ื”ืงืฆื•ื‘" + +#: lib/pleroma/web/uploader_controller.ex:23 +#, elixir-format +msgid "bad request" +msgstr "ื‘ืงืฉื” ืฉื’ื•ื™ื™ื”" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:103 +#, elixir-format +msgid "CAPTCHA Error" +msgstr "ืฉื’ื™ืืช CAPTCHA" + +#: lib/pleroma/web/common_api/common_api.ex:290 +#, elixir-format +msgid "Could not add reaction emoji" +msgstr "ืœื ื ื™ืชืŸ ืœื”ื•ืกื™ืฃ ืกืžืœื•ืŸ ืชื’ื•ื‘ื”" + +#: lib/pleroma/web/common_api/common_api.ex:301 +#, elixir-format +msgid "Could not remove reaction emoji" +msgstr "ืœื ื ื™ืชืŸ ืœื”ืกื™ืจ ืกืžืœื•ืŸ ืชื’ื•ื‘ื”" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:129 +#, elixir-format +msgid "Invalid CAPTCHA (Missing parameter: %{name})" +msgstr "CAPTCHA ืœื ืชืงื ื™ (ื—ืกืจ ืคืจืžื˜ืจ: %{name})" + +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 +#, elixir-format +msgid "List not found" +msgstr "ืจืฉื™ืžื” ืœื ื ืžืฆืื”" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:123 +#, elixir-format +msgid "Missing parameter: %{name}" +msgstr "ื—ืกืจ ืคืจืžื˜ืจ: %{name}" + +#: lib/pleroma/web/oauth/oauth_controller.ex:210 +#: lib/pleroma/web/oauth/oauth_controller.ex:321 +#, elixir-format +msgid "Password reset is required" +msgstr "ื ื“ืจืฉ ืื™ืคื•ืก ืกื™ืกืžื”" + +#: lib/pleroma/tests/auth_test_controller.ex:9 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/config_controller.ex:6 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/invite_controller.ex:6 lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex:6 lib/pleroma/web/admin_api/controllers/relay_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/report_controller.ex:6 lib/pleroma/web/admin_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/embed_controller.ex:6 +#: lib/pleroma/web/fallback_redirect_controller.ex:6 lib/pleroma/web/feed/tag_controller.ex:6 +#: lib/pleroma/web/feed/user_controller.ex:6 lib/pleroma/web/mailer/subscription_controller.ex:2 +#: lib/pleroma/web/masto_fe_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 +#: lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 +#: lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 +#: lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 +#: lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 lib/pleroma/web/oauth/fallback_controller.ex:6 +#: lib/pleroma/web/oauth/mfa_controller.ex:10 lib/pleroma/web/oauth/oauth_controller.ex:6 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/chat_controller.ex:5 lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:2 lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/notification_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:6 lib/pleroma/web/twitter_api/twitter_api_controller.ex:6 +#: lib/pleroma/web/uploader_controller.ex:6 lib/pleroma/web/web_finger/web_finger_controller.ex:6 +#, elixir-format +msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." +msgstr "ื”ืคืจืช ืื‘ื˜ื—ื”: OAuth ื‘ื‘ื“ื™ืงืช ื”ืžืชื—ื ืœื ื ื‘ื“ืงื” ืื• ื“ื•ืœื’ื” ื‘ืžื›ื•ื•ืŸ." + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 +#, elixir-format +msgid "Two-factor authentication enabled, you must use a access token." +msgstr "ืื™ืžื•ืช ื“ื•-ืฉืœื‘ื™ ื”ื•ืคืขืœ, ื™ืฉ ืœื”ื–ื™ืŸ ืืกื™ืžื•ืŸ ื›ื ื™ืกื”." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:210 +#, elixir-format +msgid "Unexpected error occurred while adding file to pack." +msgstr "ืื™ืจืขื” ืฉื’ื™ืื” ืœื ืฆืคื•ื™ื” ื‘ื–ืžืŸ ื”ื•ืกืคืช ื”ืงื•ื‘ืฅ ืœื—ื‘ื™ืœื”." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:138 +#, elixir-format +msgid "Unexpected error occurred while creating pack." +msgstr "ืื™ืจืขื” ืฉื’ื™ืื” ืœื ืฆืคื•ื™ื” ื‘ื–ืžืŸ ื™ืฆื™ืจืช ื—ื‘ื™ืœื”." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:278 +#, elixir-format +msgid "Unexpected error occurred while removing file from pack." +msgstr "ืื™ืจืขื” ืฉื’ื™ืื” ืœื ืฆืคื•ื™ื” ื‘ื–ืžืŸ ื”ืกืจืช ื”ืงื•ื‘ืฅ ืžื”ื—ื‘ื™ืœื”." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:250 +#, elixir-format +msgid "Unexpected error occurred while updating file in pack." +msgstr "ืื™ืจืขื” ืฉื’ื™ืื” ืœื ืฆืคื•ื™ื” ื‘ื–ืžืŸ ืขื“ื›ื•ืŸ ื”ืงื•ื‘ืฅ ืžื”ื—ื‘ื™ืœื”." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:179 +#, elixir-format +msgid "Unexpected error occurred while updating pack metadata." +msgstr "ืื™ืจืขื” ืฉื’ื™ืื” ืœื ืฆืคื•ื™ื” ื‘ื–ืžืŸ ืขื“ื›ื•ืŸ ืžื˜ื-ื“ืื˜ื” ืฉืœ ื”ื—ื‘ื™ืœื”." + +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 +#, elixir-format +msgid "Web push subscription is disabled on this Pleroma instance" +msgstr "ื”ืจืฉืžื” ืœืขื“ื›ื•ืŸ ื•ื•ื‘ ื‘ื“ื—ื™ืคื” ืžื‘ื•ื˜ืœืช ื‘ืฉืจืช ืคืœืจื•ืžื” ื–ื”" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:451 +#, elixir-format +msgid "You can't revoke your own admin/moderator status." +msgstr "ืœื ื ื™ืชืŸ ืœืฉืœื•ืœ ืืช ืกื˜ื˜ื•ืก ื”ืื“ืžื™ืŸ/ืžื ื”ืœ ืฉืœ ืขืฆืžืš." + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:126 +#, elixir-format +msgid "authorization required for timeline view" +msgstr "ื”ืจืฉืื” ื“ืจื•ืฉื” ืขืœ ืžื ืช ืœืฆืคื•ืช ื‘ืฆื™ืจ ื”ื–ืžืŸ" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:24 +#, elixir-format +msgid "Access denied" +msgstr "ื’ื™ืฉื” ื ื“ื—ื™ืช" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:282 +#, elixir-format +msgid "This API requires an authenticated user" +msgstr "ื”-API ื“ื•ืจืฉ ื”ืจืฉืืช ืžืฉืชืžืฉ" + +#: lib/pleroma/plugs/user_is_admin_plug.ex:21 +#, elixir-format +msgid "User is not an admin." +msgstr "ืžืฉืชืžืฉ ืื™ื ื• ืžื ื”ืœ." diff --git a/priv/gettext/posix_errors.pot b/priv/gettext/posix_errors.pot new file mode 100644 index 000000000..c9f593944 --- /dev/null +++ b/priv/gettext/posix_errors.pot @@ -0,0 +1,149 @@ +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +msgid "eperm" +msgstr "" + +msgid "eacces" +msgstr "" + +msgid "eagain" +msgstr "" + +msgid "ebadf" +msgstr "" + +msgid "ebadmsg" +msgstr "" + +msgid "ebusy" +msgstr "" + +msgid "edeadlk" +msgstr "" + +msgid "edeadlock" +msgstr "" + +msgid "edquot" +msgstr "" + +msgid "eexist" +msgstr "" + +msgid "efault" +msgstr "" + +msgid "efbig" +msgstr "" + +msgid "eftype" +msgstr "" + +msgid "eintr" +msgstr "" + +msgid "einval" +msgstr "" + +msgid "eio" +msgstr "" + +msgid "eisdir" +msgstr "" + +msgid "eloop" +msgstr "" + +msgid "emfile" +msgstr "" + +msgid "emlink" +msgstr "" + +msgid "emultihop" +msgstr "" + +msgid "enametoolong" +msgstr "" + +msgid "enfile" +msgstr "" + +msgid "enobufs" +msgstr "" + +msgid "enodev" +msgstr "" + +msgid "enolck" +msgstr "" + +msgid "enolink" +msgstr "" + +msgid "enoent" +msgstr "" + +msgid "enomem" +msgstr "" + +msgid "enospc" +msgstr "" + +msgid "enosr" +msgstr "" + +msgid "enostr" +msgstr "" + +msgid "enosys" +msgstr "" + +msgid "enotblk" +msgstr "" + +msgid "enotdir" +msgstr "" + +msgid "enotsup" +msgstr "" + +msgid "enxio" +msgstr "" + +msgid "eopnotsupp" +msgstr "" + +msgid "eoverflow" +msgstr "" + +msgid "epipe" +msgstr "" + +msgid "erange" +msgstr "" + +msgid "erofs" +msgstr "" + +msgid "espipe" +msgstr "" + +msgid "esrch" +msgstr "" + +msgid "estale" +msgstr "" + +msgid "etxtbsy" +msgstr "" + +msgid "exdev" +msgstr "" diff --git a/priv/gettext/uk/LC_MESSAGES/errors.po b/priv/gettext/uk/LC_MESSAGES/errors.po new file mode 100644 index 000000000..9638761ec --- /dev/null +++ b/priv/gettext/uk/LC_MESSAGES/errors.po @@ -0,0 +1,599 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-12-10 16:09+0000\n" +"PO-Revision-Date: 2020-12-11 00:56+0000\n" +"Last-Translator: ZEN \n" +"Language-Team: Ukrainian \n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=" +"4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" +"X-Generator: Weblate 4.0.4\n" + +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "ะฝะต ะผะพะถะต ะฑัƒั‚ะธ ะฟัƒัั‚ะธะผ" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "ะฒะถะต ะทะฐะนะฝัั‚ะพ" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "ะฝะตะดั–ะนัะฝะธะน" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "ะผะฐั” ะฝะตะดั–ะนัะฝะธะน ั„ะพั€ะผะฐั‚" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "ะผะฐั” ะฝะตะดั–ะนัะฝะธะน ะทะฐะฟะธั" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "ะทะฐั€ะตะทะตั€ะฒะพะฒะฐะฝะพ" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "ะฝะต ะทะฑั–ะณะฐั”ั‚ัŒัั ะท ะฟั–ะดั‚ะฒะตั€ะดะถะตะฝะฝัะผ" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "ะฒัะต ั‰ะต ะฟะพะฒ'ัะทะฐะฝะธะน ะท ั†ะธะผ ะทะฐะฟะธัะพะผ" + +msgid "are still associated with this entry" +msgstr "ะฒัะต ั‰ะต ะฟะพะฒ'ัะทะฐะฝั– ะท ั†ะธะผ ะทะฐะฟะธัะพะผ" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ %{count} ัะธะผะฒะพะป" +msgstr[1] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ %{count} ัะธะผะฒะพะปะธ" +msgstr[2] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ %{count} ัะธะผะฒะพะปั–ะฒ" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ %{count} ะตะปะตะผะตะฝั‚" +msgstr[1] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ %{count} ะตะปะตะผะตะฝั‚ะธ" +msgstr[2] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ %{count} ะตะปะตะผะตะฝั‚ั–ะฒ" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ั…ะพั‡ะฐ ะฑ %{count} ัะธะผะฒะพะป" +msgstr[1] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ั…ะพั‡ะฐ ะฑ %{count} ัะธะผะฒะพะปะธ" +msgstr[2] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ั…ะพั‡ะฐ ะฑ %{count} ัะธะผะฒะพะปั–ะฒ" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ั…ะพั‡ะฐ ะฑ %{count} ะตะปะตะผะตะฝั‚" +msgstr[1] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ั…ะพั‡ะฐ ะฑ %{count} ะตะปะตะผะตะฝั‚ะธ" +msgstr[2] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ั…ะพั‡ะฐ ะฑ %{count} ะตะปะตะผะตะฝั‚ั–ะฒ" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "ะฟะพะฒะธะฝะตะฝ ะฑัƒั‚ะธ ะฝะต ะฑั–ะปัŒัˆะต %{count} ัะธะผะฒะพะปัƒ" +msgstr[1] "ะฟะพะฒะธะฝะตะฝ ะฑัƒั‚ะธ ะฝะต ะฑั–ะปัŒัˆะต %{count} ัะธะผะฒะพะปั–ะฒ" +msgstr[2] "ะฟะพะฒะธะฝะตะฝ ะฑัƒั‚ะธ ะฝะต ะฑั–ะปัŒัˆะต %{count} ัะธะผะฒะพะปั–ะฒ" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ะฝะต ะฑั–ะปัŒัˆะต %{count} ะตะปะตะผะตะฝั‚ะฐ" +msgstr[1] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ะฝะต ะฑั–ะปัŒัˆะต %{count} ะตะปะตะผะตะฝั‚ั–ะฒ" +msgstr[2] "ะฟะพะฒะธะฝะตะฝ ะผั–ัั‚ะธั‚ะธ ะฝะต ะฑั–ะปัŒัˆะต %{count} ะตะปะตะผะตะฝั‚ั–ะฒ" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "ะฟะพะฒะธะฝะตะฝ ะผะฐั‚ะธ ะทะฝะฐั‡ะตะฝะฝั ะผะตะฝัˆะต ะฝั–ะถ %{number}" + +msgid "must be greater than %{number}" +msgstr "ะฟะพะฒะธะฝะตะฝ ะผะฐั‚ะธ ะทะฝะฐั‡ะตะฝะฝั ะฑั–ะปัŒัˆะต ะฝั–ะถ %{number}" + +msgid "must be less than or equal to %{number}" +msgstr "ะฟะพะฒะธะฝะตะฝ ะผะฐั‚ะธ ะทะฝะฐั‡ะตะฝะฝั ะผะตะฝัˆะต ะฐะฑะพ ั€ั–ะฒะฝะต %{number}" + +msgid "must be greater than or equal to %{number}" +msgstr "ะฟะพะฒะธะฝะตะฝ ะผะฐั‚ะธ ะทะฝะฐั‡ะตะฝะฝั ะฑั–ะปัŒัˆะต ะฐะฑะพ ั€ั–ะฒะฝะต %{number}" + +msgid "must be equal to %{number}" +msgstr "ะฟะพะฒะธะฝะตะฝ ะผะฐั‚ะธ ะปะธัˆะต ะทะฝะฐั‡ะตะฝะฝั, ั€ั–ะฒะฝะต %{number}" + +#: lib/pleroma/web/common_api/common_api.ex:505 +#, elixir-format +msgid "Account not found" +msgstr "ะžะฑะปั–ะบะพะฒะธะน ะทะฐะฟะธั ะฝะต ะทะฝะฐะนะดะตะฝะพ" + +#: lib/pleroma/web/common_api/common_api.ex:339 +#, elixir-format +msgid "Already voted" +msgstr "ะ’ะถะต ะฟั€ะพะณะพะปะพัะพะฒะฐะฝะพ" + +#: lib/pleroma/web/oauth/oauth_controller.ex:359 +#, elixir-format +msgid "Bad request" +msgstr "ะะตะฒั–ั€ะฝะธะน ะทะฐะฟะธั‚" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:426 +#, elixir-format +msgid "Can't delete object" +msgstr "ะ’ะธะฝะธะบะปะฐ ะฟะพะผะธะปะบะฐ ะฟั€ะธ ะฒะธะดะฐะปะตะฝะฝั– ะพะฑ'ั”ะบั‚ัƒ" + +#: lib/pleroma/web/controller_helper.ex:105 +#: lib/pleroma/web/controller_helper.ex:111 +#, elixir-format +msgid "Can't display this activity" +msgstr "ะะต ะฒะดะฐั”ั‚ัŒัั ะฒั–ะดะพะฑั€ะฐะทะธั‚ะธ ั†ัŽ ะฐะบั‚ะธะฒะฝั–ัั‚ัŒ" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:285 +#, elixir-format +msgid "Can't find user" +msgstr "ะšะพั€ะธัั‚ัƒะฒะฐั‡ะฐ ะฝะต ะทะฝะฐะนะดะตะฝะพ" + +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:61 +#, elixir-format +msgid "Can't get favorites" +msgstr "ะะต ะฒะดะฐั”ั‚ัŒัั ะพั‚ั€ะธะผะฐั‚ะธ ะฒะฟะพะดะพะฑะฐะฝะฝั" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:438 +#, elixir-format +msgid "Can't like object" +msgstr "ะะต ะฒะดะฐั”ั‚ัŒัั ะฒะฟะพะดะพะฑะฐั‚ะธ ะพะฑโ€™ั”ะบั‚" + +#: lib/pleroma/web/common_api/utils.ex:563 +#, elixir-format +msgid "Cannot post an empty status without attachments" +msgstr "ะะต ะฒะดะฐั”ั‚ัŒัั ะพะฟัƒะฑะปั–ะบัƒะฒะฐั‚ะธ ะฟะพั€ะพะถะฝั” ะฟะพะฒั–ะดะพะผะปะตะฝะฝั ะฑะตะท ะฒะบะปะฐะดะตะฝัŒ" + +#: lib/pleroma/web/common_api/utils.ex:511 +#, elixir-format +msgid "Comment must be up to %{max_size} characters" +msgstr "ะšะพะผะตะฝั‚ะฐั€ ะผะพะถะต ะผั–ัั‚ะธั‚ะธ ะฝะต ะฑั–ะปัŒัˆะต %{max_size} ัะธะผะฒะพะปั–ะฒ" + +#: lib/pleroma/config/config_db.ex:191 +#, elixir-format +msgid "Config with params %{params} not found" +msgstr "ะšะพะฝั„ั–ะณัƒั€ะฐั†ั–ั ะท ะฟะฐั€ะฐะผะตั‚ั€ะฐะผะธ %{params} ะฝะต ะทะฝะฐะนะดะตะฝะฐ" + +#: lib/pleroma/web/common_api/common_api.ex:181 +#: lib/pleroma/web/common_api/common_api.ex:185 +#, elixir-format +msgid "Could not delete" +msgstr "ะะต ะผะพะถัƒ ะฒะธะดะฐะปะธั‚ะธ" + +#: lib/pleroma/web/common_api/common_api.ex:231 +#, elixir-format +msgid "Could not favorite" +msgstr "ะะต ะฒะดะฐะปะพัั ะดะพะดะฐั‚ะธ ะดะพ ะฒะฟะพะดะพะฑะฐะฝะพะณะพ" + +#: lib/pleroma/web/common_api/common_api.ex:453 +#, elixir-format +msgid "Could not pin" +msgstr "ะะต ะฒะดะฐะปะพัั ะทะฐะบั€ั–ะฟะธั‚ะธ" + +#: lib/pleroma/web/common_api/common_api.ex:278 +#, elixir-format +msgid "Could not unfavorite" +msgstr "ะะต ะฒะดะฐะปะพัั ะฒะธะดะฐะปะธั‚ะธ ะท ะฒะฟะพะดะพะฑะฐะฝะพะณะพ" + +#: lib/pleroma/web/common_api/common_api.ex:463 +#, elixir-format +msgid "Could not unpin" +msgstr "ะะต ะฒะดะฐะปะพัั ะฒั–ะดะบั€ั–ะฟะธั‚ะธ" + +#: lib/pleroma/web/common_api/common_api.ex:216 +#, elixir-format +msgid "Could not unrepeat" +msgstr "ะะต ะฒะดะฐะปะพัั ัะบะฐััƒะฒะฐั‚ะธ ะฟะพัˆะธั€ะตะฝะฝั" + +#: lib/pleroma/web/common_api/common_api.ex:512 +#: lib/pleroma/web/common_api/common_api.ex:521 +#, elixir-format +msgid "Could not update state" +msgstr "ะะต ะฒะดะฐะปะพัั ะพะฝะพะฒะธั‚ะธ ัั‚ะฐะฝ" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:207 +#, elixir-format +msgid "Error." +msgstr "ะŸะพะผะธะปะบะฐ." + +#: lib/pleroma/web/twitter_api/twitter_api.ex:106 +#, elixir-format +msgid "Invalid CAPTCHA" +msgstr "ะะตะฒั–ั€ะฝะฐ CAPTCHA" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:568 +#, elixir-format +msgid "Invalid credentials" +msgstr "ะะตะฟั€ะฐะฒะธะปัŒะฝั– ะดะฐะฝั– ะฐะฒั‚ะตะฝั‚ะธั„ั–ะบะฐั†ั–ั—" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 +#, elixir-format +msgid "Invalid credentials." +msgstr "ะะตะฟั€ะฐะฒะธะปัŒะฝั– ะดะฐะฝั– ะฐะฒั‚ะตะฝั‚ะธั„ั–ะบะฐั†ั–ั—." + +#: lib/pleroma/web/common_api/common_api.ex:355 +#, elixir-format +msgid "Invalid indices" +msgstr "ะะตะฟั€ะฐะฒะธะปัŒะฝั– ั–ะฝะดะตะบัะธ" + +#: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid parameters" +msgstr "ะะตะฟั€ะฐะฒะธะปัŒะฝั– ะฟะฐั€ะฐะผะตั‚ั€ะธ" + +#: lib/pleroma/web/common_api/utils.ex:414 +#, elixir-format +msgid "Invalid password." +msgstr "ะะตะฟั€ะฐะฒะธะปัŒะฝะธะน ะฟะฐั€ะพะปัŒ." + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:220 +#, elixir-format +msgid "Invalid request" +msgstr "ะะตะฒั–ั€ะฝะธะน ะทะฐะฟะธั‚" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:109 +#, elixir-format +msgid "Kocaptcha service unavailable" +msgstr "ะกะตั€ะฒั–ั Kocaptcha ะฝะตะดะพัั‚ัƒะฟะฝะธะน" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:112 +#, elixir-format +msgid "Missing parameters" +msgstr "ะ’ั–ะดััƒั‚ะฝั– ะฟะฐั€ะฐะผะตั‚ั€ะธ" + +#: lib/pleroma/web/common_api/utils.ex:547 +#, elixir-format +msgid "No such conversation" +msgstr "ะะตะผะฐั” ั‚ะฐะบะพั— ั€ะพะทะผะพะฒะธ" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:388 +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:414 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:456 +#, elixir-format +msgid "No such permission_group" +msgstr "ะะต ั–ัะฝัƒั” ั‚ะฐะบะพั— ะณั€ัƒะฟะธ ะฟะพะฒะฝะพะฒะฐะถะตะฝัŒ" + +#: lib/pleroma/plugs/uploaded_media.ex:84 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:486 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:11 +#: lib/pleroma/web/feed/user_controller.ex:71 lib/pleroma/web/ostatus/ostatus_controller.ex:143 +#, elixir-format +msgid "Not found" +msgstr "ะะต ะทะฝะฐะนะดะตะฝะพ" + +#: lib/pleroma/web/common_api/common_api.ex:331 +#, elixir-format +msgid "Poll's author can't vote" +msgstr "ะะฒั‚ะพั€ ะพะฟะธั‚ัƒะฒะฐะฝะฝั ะฝะต ะผะพะถะต ะณะพะปะพััƒะฒะฐั‚ะธ" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:306 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 +#, elixir-format +msgid "Record not found" +msgstr "ะ—ะฐะฟะธั ะฝะต ะทะฝะฐะนะดะตะฝะพ" + +#: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:35 +#: lib/pleroma/web/feed/user_controller.ex:77 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:36 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:149 +#, elixir-format +msgid "Something went wrong" +msgstr "ะฉะพััŒ ะทะปะฐะผะฐะปะพัั" + +#: lib/pleroma/web/common_api/activity_draft.ex:107 +#, elixir-format +msgid "The message visibility must be direct" +msgstr "ะ’ะธะดะธะผั–ัั‚ัŒ ัƒ ะฟะพะฒั–ะดะพะผะปะตะฝะฝั ะฟะพะฒะธะฝะฝะฐ ะฑัƒั‚ะธ `ะŸั€ะธะฒะฐั‚ะฝะธะน`" + +#: lib/pleroma/web/common_api/utils.ex:573 +#, elixir-format +msgid "The status is over the character limit" +msgstr "ะฆะตะน ัั‚ะฐั‚ัƒั ะฟะตั€ะตะฒะธั‰ัƒั” ะปั–ะผั–ั‚ ัะธะผะฒะพะปั–ะฒ" + +#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 +#, elixir-format +msgid "This resource requires authentication." +msgstr "ะฆะตะน ั€ะตััƒั€ั ะฒะธะผะฐะณะฐั” ะฐะฒั‚ะตะฝั‚ะธั„ั–ะบะฐั†ั–ั—." + +#: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 +#, elixir-format +msgid "Throttled" +msgstr "ะžะฑะผะตะถะตะฝะพ. ะŸะตั€ะตะฒะธั‰ะตะฝะพ ะปั–ะผั–ั‚ ะทะฐะฟะธั‚ั–ะฒ." + +#: lib/pleroma/web/common_api/common_api.ex:356 +#, elixir-format +msgid "Too many choices" +msgstr "ะ—ะฐะฑะฐะณะฐั‚ะพ ะฒะฐั€ั–ะฐะฝั‚ั–ะฒ ะฒะธะฑะพั€ัƒ" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:443 +#, elixir-format +msgid "Unhandled activity type" +msgstr "ะะตะฟั–ะดั‚ั€ะธะผัƒะฒะฐะฝะธะน ั‚ะธะฟ ะฐะบั‚ะธะฒะฝะพัั‚ั–" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:485 +#, elixir-format +msgid "You can't revoke your own admin status." +msgstr "ะ’ะธ ะฝะต ะผะพะถะตั‚ะต ะฟะพะทะฑะฐะฒะธั‚ะธ ัะฐะผะพะณะพ ัะตะฑะต ัั‚ะฐั‚ัƒััƒ ะฐะดะผั–ะฝั–ัั‚ั€ะฐั‚ะพั€ะฐ." + +#: lib/pleroma/web/oauth/oauth_controller.ex:221 +#: lib/pleroma/web/oauth/oauth_controller.ex:308 +#, elixir-format +msgid "Your account is currently disabled" +msgstr "ะ’ะฐัˆ ะพะฑะปั–ะบะพะฒะธะน ะทะฐะฟะธั ะฝะฐั€ะฐะทั– ะฒะธะผะบะฝะตะฝะพ" + +#: lib/pleroma/web/oauth/oauth_controller.ex:183 +#: lib/pleroma/web/oauth/oauth_controller.ex:331 +#, elixir-format +msgid "Your login is missing a confirmed e-mail address" +msgstr "ะ’ะฐัˆะฐ ะตะปะตะบั‚ั€ะพะฝะฐ ะฐะดั€ะตัะฐ ะฝะต ะฟั–ะดั‚ะฒะตั€ะดะถะตะฝะฐ" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:390 +#, elixir-format +msgid "can't read inbox of %{nickname} as %{as_nickname}" +msgstr "" +"ะะต ะฒะดะฐั”ั‚ัŒัั ะฟั€ะพั‡ะธั‚ะฐั‚ะธ \"ะ’ั…ั–ะดะฝั–\" ะฟะพะฒั–ะดะพะผะปะตะฝะฝั %{nickname} ัะบ %{as_nickname}" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:473 +#, elixir-format +msgid "can't update outbox of %{nickname} as %{as_nickname}" +msgstr "" +"ะะต ะฒะดะฐั”ั‚ัŒัั ะพะฝะพะฒะธั‚ะธ \"ะ’ะธั…ั–ะดะฝั–\" ะฟะพะฒั–ะดะพะผะปะตะฝะฝั %{nickname} ัะบ %{as_nickname}" + +#: lib/pleroma/web/common_api/common_api.ex:471 +#, elixir-format +msgid "conversation is already muted" +msgstr "ะ ะพะทะผะพะฒะฐ ะฒะถะต ะทะฐะณะปัƒัˆะตะฝะฐ" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:314 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:492 +#, elixir-format +msgid "error" +msgstr "ะฟะพะผะธะปะบะฐ" + +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:32 +#, elixir-format +msgid "mascots can only be images" +msgstr "ั‚ะฐะปั–ัะผะฐะฝะฐะผะธ ะผะพะถัƒั‚ัŒ ะฑัƒั‚ะธ ะปะธัˆะต ะทะพะฑั€ะฐะถะตะฝะฝั" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:62 +#, elixir-format +msgid "not found" +msgstr "ะฝะต ะทะฝะฐะนะดะตะฝะพ" + +#: lib/pleroma/web/oauth/oauth_controller.ex:394 +#, elixir-format +msgid "Bad OAuth request." +msgstr "ะะตะฒั–ั€ะฝะธะน ะทะฐะฟะธั‚ OAuth." + +#: lib/pleroma/web/twitter_api/twitter_api.ex:115 +#, elixir-format +msgid "CAPTCHA already used" +msgstr "CAPTCHA ะฒะถะต ะฒะธะบะพั€ะธัั‚ะฐะฝะฐ" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:112 +#, elixir-format +msgid "CAPTCHA expired" +msgstr "ะขะตั€ะผั–ะฝ ะดั–ั— CAPTCHA ะทะฐะบั–ะฝั‡ะธะฒัั" + +#: lib/pleroma/plugs/uploaded_media.ex:57 +#, elixir-format +msgid "Failed" +msgstr "ะะต ะฒะดะฐะปะพัั" + +#: lib/pleroma/web/oauth/oauth_controller.ex:410 +#, elixir-format +msgid "Failed to authenticate: %{message}." +msgstr "ะŸะพะผะธะปะบะฐ ะฐะฒั‚ะตะฝั‚ะธั„ั–ะบะฐั†ั–ั—: %{message}." + +#: lib/pleroma/web/oauth/oauth_controller.ex:441 +#, elixir-format +msgid "Failed to set up user account." +msgstr "ะะต ะฒะดะฐะปะพัั ัั‚ะฒะพั€ะธั‚ะธ ะพะฑะปั–ะบะพะฒะธะน ะทะฐะฟะธั." + +#: lib/pleroma/plugs/oauth_scopes_plug.ex:38 +#, elixir-format +msgid "Insufficient permissions: %{permissions}." +msgstr "ะะตะดะพัั‚ะฐั‚ะฝัŒะพ ะฟั€ะฐะฒ: %{permissions}." + +#: lib/pleroma/plugs/uploaded_media.ex:104 +#, elixir-format +msgid "Internal Error" +msgstr "ะ’ะฝัƒั‚ั€ั–ัˆะฝั ะฟะพะผะธะปะบะฐ" + +#: lib/pleroma/web/oauth/fallback_controller.ex:22 +#: lib/pleroma/web/oauth/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid Username/Password" +msgstr "ะะตะฟั€ะฐะฒะธะปัŒะฝะต ั–ะผ'ั ะบะพั€ะธัั‚ัƒะฒะฐั‡ะฐ ะฐะฑะพ ะฟะฐั€ะพะปัŒ" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:118 +#, elixir-format +msgid "Invalid answer data" +msgstr "ะะตะฟั€ะฐะฒะธะปัŒะฝะฐ ะฒั–ะดะฟะพะฒั–ะดัŒ" + +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:33 +#, elixir-format +msgid "Nodeinfo schema version not handled" +msgstr "ะ’ะตั€ัั–ั ัั…ะตะผะธ Nodeinfo ะฝะต ะฒั€ะฐั…ะพะฒัƒั”ั‚ัŒัั" + +#: lib/pleroma/web/oauth/oauth_controller.ex:172 +#, elixir-format +msgid "This action is outside the authorized scopes" +msgstr "ะฆั ะดั–ั ะฒะธั…ะพะดะธั‚ัŒ ะทะฐ ั€ะฐะผะบะธ ะดะพัั‚ัƒะฟะฝะธั… ะฟะพะฒะฝะพะฒะฐะถะตะฝัŒ" + +#: lib/pleroma/web/oauth/fallback_controller.ex:14 +#, elixir-format +msgid "Unknown error, please check the details and try again." +msgstr "ะะตะฒั–ะดะพะผะฐ ะฟะพะผะธะปะบะฐ. ะŸะตั€ะตะฒั–ั€ั‚ะต ะดะตั‚ะฐะปั– ั‚ะฐ ะฟะพะฒั‚ะพั€ั–ั‚ัŒ ัะฟั€ะพะฑัƒ." + +#: lib/pleroma/web/oauth/oauth_controller.ex:119 +#: lib/pleroma/web/oauth/oauth_controller.ex:158 +#, elixir-format +msgid "Unlisted redirect_uri." +msgstr "ะะตะฒั–ะดะพะผะธะน redirect_uri." + +#: lib/pleroma/web/oauth/oauth_controller.ex:390 +#, elixir-format +msgid "Unsupported OAuth provider: %{provider}." +msgstr "ะะตะฟั–ะดั‚ั€ะธะผัƒะฒะฐะฝะธะน ะฟะพัั‚ะฐั‡ะฐะปัŒะฝะธะบ ะฟะพัะปัƒะณ OAuth: %{provider}." + +#: lib/pleroma/uploaders/uploader.ex:72 +#, elixir-format +msgid "Uploader callback timeout" +msgstr "ะขะฐะนะผ-ะฐัƒั‚ ะฟั€ะธ ะทะฐะฒะฐะฝั‚ะฐะถะตะฝะฝั–" + +#: lib/pleroma/web/uploader_controller.ex:23 +#, elixir-format +msgid "bad request" +msgstr "ะฝะตะฒั–ั€ะฝะธะน ะทะฐะฟะธั‚" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:103 +#, elixir-format +msgid "CAPTCHA Error" +msgstr "ะŸะพะผะธะปะบะฐ CAPTCHA" + +#: lib/pleroma/web/common_api/common_api.ex:290 +#, elixir-format +msgid "Could not add reaction emoji" +msgstr "ะะต ะฒะดะฐะปะพัั ะดะพะดะฐั‚ะธ ะตะผะพะดะทั– ะดะปั ั€ะตะฐะบั†ั–ั—" + +#: lib/pleroma/web/common_api/common_api.ex:301 +#, elixir-format +msgid "Could not remove reaction emoji" +msgstr "ะะต ะฒะดะฐะปะพัั ะฒะธะดะฐะปะธั‚ะธ ั€ะตะฐะบั†ั–ัŽ" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:129 +#, elixir-format +msgid "Invalid CAPTCHA (Missing parameter: %{name})" +msgstr "ะะตะดั–ะนัะฝะฐ CAPTCHA (ะ’ั–ะดััƒั‚ะฝั–ะน ะฟะฐั€ะฐะผะตั‚ั€: %{name})" + +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 +#, elixir-format +msgid "List not found" +msgstr "ะกะฟะธัะพะบ ะฝะต ะทะฝะฐะนะดะตะฝะพ" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:123 +#, elixir-format +msgid "Missing parameter: %{name}" +msgstr "ะ’ั–ะดััƒั‚ะฝั–ะน ะฟะฐั€ะฐะผะตั‚ั€: %{name}" + +#: lib/pleroma/web/oauth/oauth_controller.ex:210 +#: lib/pleroma/web/oauth/oauth_controller.ex:321 +#, elixir-format +msgid "Password reset is required" +msgstr "ะŸะพั‚ั€ั–ะฑะฝะพ ัะบะธะฝัƒั‚ะธ ะฟะฐั€ะพะปัŒ" + +#: lib/pleroma/tests/auth_test_controller.ex:9 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/config_controller.ex:6 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/invite_controller.ex:6 lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex:6 lib/pleroma/web/admin_api/controllers/relay_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/report_controller.ex:6 lib/pleroma/web/admin_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/embed_controller.ex:6 +#: lib/pleroma/web/fallback_redirect_controller.ex:6 lib/pleroma/web/feed/tag_controller.ex:6 +#: lib/pleroma/web/feed/user_controller.ex:6 lib/pleroma/web/mailer/subscription_controller.ex:2 +#: lib/pleroma/web/masto_fe_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 +#: lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 +#: lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 +#: lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 +#: lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 lib/pleroma/web/oauth/fallback_controller.ex:6 +#: lib/pleroma/web/oauth/mfa_controller.ex:10 lib/pleroma/web/oauth/oauth_controller.ex:6 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/chat_controller.ex:5 lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:2 lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/notification_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:6 lib/pleroma/web/twitter_api/twitter_api_controller.ex:6 +#: lib/pleroma/web/uploader_controller.ex:6 lib/pleroma/web/web_finger/web_finger_controller.ex:6 +#, elixir-format +msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." +msgstr "" +"ะŸะพั€ัƒัˆะตะฝะฝั ะฑะตะทะฟะตะบะธ: ะฟะตั€ะตะฒั–ั€ะบะฐ ะพะฑััะณัƒ OAuth ะฝะต ะฑัƒะปะฐ ะพะฑั€ะพะฑะปะตะฝะฐ, ะฝั– ัะฒะฝะพ " +"ะฟั€ะพะฟัƒั‰ะตะฝะฐ." + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 +#, elixir-format +msgid "Two-factor authentication enabled, you must use a access token." +msgstr "" +"ะ”ะฒะพั„ะฐะบั‚ะพั€ะฝะฐ ะฐะฒั‚ะตะฝั‚ะธั„ั–ะบะฐั†ั–ั ะฒะฒั–ะผะบะฝะตะฝะฐ, ะฒะธ ะฟะพะฒะธะฝะฝั– ะฒะธะบะพั€ะธัั‚ะพะฒัƒะฒะฐั‚ะธ ะบะปัŽั‡ " +"ะดะพัั‚ัƒะฟัƒ." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:210 +#, elixir-format +msgid "Unexpected error occurred while adding file to pack." +msgstr "ะะตัะฟะพะดั–ะฒะฐะฝะฐ ะฟะพะผะธะปะบะฐ ะฟั€ะธ ะดะพะดะฐะฒะฐะฝะฝั– ั„ะฐะนะปัƒ ะฒ ะฟะฐะบะตั‚." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:138 +#, elixir-format +msgid "Unexpected error occurred while creating pack." +msgstr "ะะตัะฟะพะดั–ะฒะฐะฝะฐ ะฟะพะผะธะปะบะฐ ะฟั–ะด ั‡ะฐั ัั‚ะฒะพั€ะตะฝะฝั ะฟะฐะบะตั‚ะฐ." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:278 +#, elixir-format +msgid "Unexpected error occurred while removing file from pack." +msgstr "ะŸั–ะด ั‡ะฐั ะฒะธะดะฐะปะตะฝะฝั ั„ะฐะนะปัƒ ะท ะฟะฐะบะตั‚ะฐ ัั‚ะฐะปะฐัั ะฝะตัะฟะพะดั–ะฒะฐะฝะฐ ะฟะพะผะธะปะบะฐ." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:250 +#, elixir-format +msgid "Unexpected error occurred while updating file in pack." +msgstr "ะŸั–ะด ั‡ะฐั ะพะฝะพะฒะปะตะฝะฝั ั„ะฐะนะปัƒ ะฒ ะฟะฐะบะตั‚ั– ัั‚ะฐะปะฐัั ะฝะตัะฟะพะดั–ะฒะฐะฝะฐ ะฟะพะผะธะปะบะฐ." + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:179 +#, elixir-format +msgid "Unexpected error occurred while updating pack metadata." +msgstr "ะŸั–ะด ั‡ะฐั ะพะฝะพะฒะปะตะฝะฝั ะผะตั‚ะฐะดะฐะฝะธั… ะฟะฐะบะตั‚ะฐ ัั‚ะฐะปะฐัั ะฝะตัะฟะพะดั–ะฒะฐะฝะฐ ะฟะพะผะธะปะบะฐ." + +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 +#, elixir-format +msgid "Web push subscription is disabled on this Pleroma instance" +msgstr "Web push-ัะฟะพะฒั–ั‰ะตะฝะฝั ะฒะธะผะบะฝะตะฝั– ะฝะฐ ั†ัŒะพะผัƒ ั–ะฝัั‚ะฐะฝัั– Pleroma" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:451 +#, elixir-format +msgid "You can't revoke your own admin/moderator status." +msgstr "ะ’ะธ ะฝะต ะผะพะถะตั‚ะต ะฟะพะทะฑะฐะฒะธั‚ะธ ัะฐะผะพะณะพ ัะตะฑะต ัั‚ะฐั‚ัƒััƒ ะฐะดะผั–ะฝั–ัั‚ั€ะฐั‚ะพั€ะฐ/ะผะพะดะตั€ะฐั‚ะพั€ะฐ." + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:126 +#, elixir-format +msgid "authorization required for timeline view" +msgstr "ะฝะตะพะฑั…ั–ะดะฝะพ ะฒะฒั–ะนั‚ะธ ะฒ ัะธัั‚ะตะผัƒ ะดะปั ะฟะตั€ะตะณะปัะดัƒ ัั‚ั€ั–ั‡ะบะธ ะฟะพะฒั–ะดะพะผะปะตะฝัŒ" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:24 +#, elixir-format +msgid "Access denied" +msgstr "ะ”ะพัั‚ัƒะฟ ะทะฐะฑะพั€ะพะฝะตะฝะพ" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:282 +#, elixir-format +msgid "This API requires an authenticated user" +msgstr "ะฆะตะน API ะฒะธะผะฐะณะฐั” ะฐะฒั‚ะตะฝั‚ะธั„ั–ะบะพะฒะฐะฝะพะณะพ ะบะพั€ะธัั‚ัƒะฒะฐั‡ะฐ" + +#: lib/pleroma/plugs/user_is_admin_plug.ex:21 +#, elixir-format +msgid "User is not an admin." +msgstr "ะšะพั€ะธัั‚ัƒะฒะฐั‡ ะฝะต ั” ะฐะดะผั–ะฝั–ัั‚ั€ะฐั‚ะพั€ะพะผ." diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/errors.po b/priv/gettext/zh_Hans/LC_MESSAGES/errors.po index 4f029d558..ecf1dab6b 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/errors.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/errors.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-20 13:18+0000\n" -"PO-Revision-Date: 2020-09-20 14:48+0000\n" -"Last-Translator: Kana \n" +"PO-Revision-Date: 2020-12-14 06:00+0000\n" +"Last-Translator: shironeko \n" "Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" @@ -49,7 +49,7 @@ msgstr "ๆ˜ฏ่ขซไฟ็•™็š„" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" -msgstr "" +msgstr "ไธŽ้ชŒ่ฏไธ็ฌฆ" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" @@ -138,133 +138,133 @@ msgstr "ไธ่ƒฝ่Žทๅ–ๆ”ถ่—" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:438 #, elixir-format msgid "Can't like object" -msgstr "" +msgstr "ไธ่ƒฝๅ–œๆฌขๅฏน่ฑก" #: lib/pleroma/web/common_api/utils.ex:563 #, elixir-format msgid "Cannot post an empty status without attachments" -msgstr "" +msgstr "ๆ— ๆณ•ๅ‘้€็ฉบ็™ฝไธ”ไธๅŒ…ๅซ้™„ไปถ็š„็Šถๆ€" #: lib/pleroma/web/common_api/utils.ex:511 -#, elixir-format +#, elixir-format, fuzzy msgid "Comment must be up to %{max_size} characters" -msgstr "" +msgstr "่ฏ„่ฎบๆœ€ๅคšๅฏไฝฟ็”จ %{max_size} ๅญ—็ฌฆ" #: lib/pleroma/config/config_db.ex:191 #, elixir-format msgid "Config with params %{params} not found" -msgstr "" +msgstr "ๆ— ๆณ•ๆ‰พๅˆฐๅŒ…ๅซๅ‚ๆ•ฐ %{params} ็š„้…็ฝฎ" #: lib/pleroma/web/common_api/common_api.ex:181 #: lib/pleroma/web/common_api/common_api.ex:185 #, elixir-format msgid "Could not delete" -msgstr "" +msgstr "ๆ— ๆณ•ๅˆ ้™ค" #: lib/pleroma/web/common_api/common_api.ex:231 #, elixir-format msgid "Could not favorite" -msgstr "" +msgstr "ๆ— ๆณ•ๆ”ถ่—" #: lib/pleroma/web/common_api/common_api.ex:453 #, elixir-format msgid "Could not pin" -msgstr "" +msgstr "ๆ— ๆณ•็ฝฎ้กถ" #: lib/pleroma/web/common_api/common_api.ex:278 #, elixir-format msgid "Could not unfavorite" -msgstr "" +msgstr "ๆ— ๆณ•ๅ–ๆถˆๆ”ถ่—" #: lib/pleroma/web/common_api/common_api.ex:463 #, elixir-format msgid "Could not unpin" -msgstr "" +msgstr "ๆ— ๆณ•ๅ–ๆถˆ็ฝฎ้กถ" #: lib/pleroma/web/common_api/common_api.ex:216 #, elixir-format msgid "Could not unrepeat" -msgstr "" +msgstr "ๆ— ๆณ•ๅ–ๆถˆ่ฝฌๅ‘" #: lib/pleroma/web/common_api/common_api.ex:512 #: lib/pleroma/web/common_api/common_api.ex:521 #, elixir-format msgid "Could not update state" -msgstr "" +msgstr "ๆ— ๆณ•ๆ›ดๆ–ฐ็Šถๆ€" #: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:207 #, elixir-format msgid "Error." -msgstr "" +msgstr "้”™่ฏฏใ€‚" #: lib/pleroma/web/twitter_api/twitter_api.ex:106 #, elixir-format msgid "Invalid CAPTCHA" -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„้ชŒ่ฏ็ " #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:116 #: lib/pleroma/web/oauth/oauth_controller.ex:568 #, elixir-format msgid "Invalid credentials" -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„ๅ‡ญๆฎ" #: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 #, elixir-format msgid "Invalid credentials." -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„ๅ‡ญๆฎใ€‚" #: lib/pleroma/web/common_api/common_api.ex:355 #, elixir-format msgid "Invalid indices" -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„็ดขๅผ•" #: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:29 #, elixir-format msgid "Invalid parameters" -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„ๅ‚ๆ•ฐ" #: lib/pleroma/web/common_api/utils.ex:414 #, elixir-format msgid "Invalid password." -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„ๅฏ†็ ใ€‚" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:220 #, elixir-format msgid "Invalid request" -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„่ฏทๆฑ‚" #: lib/pleroma/web/twitter_api/twitter_api.ex:109 #, elixir-format msgid "Kocaptcha service unavailable" -msgstr "" +msgstr "Kocaptcha ๆœๅŠกไธๅฏ็”จ" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:112 #, elixir-format msgid "Missing parameters" -msgstr "" +msgstr "็ผบๅฐ‘ๅ‚ๆ•ฐ" #: lib/pleroma/web/common_api/utils.ex:547 #, elixir-format msgid "No such conversation" -msgstr "" +msgstr "ๆฒกๆœ‰่ฏฅๅฏน่ฏ" #: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:388 #: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:414 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:456 -#, elixir-format +#, elixir-format, fuzzy msgid "No such permission_group" -msgstr "" +msgstr "ๆฒกๆœ‰่ฏฅๆƒ้™็ป„" #: lib/pleroma/plugs/uploaded_media.ex:84 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:486 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:11 #: lib/pleroma/web/feed/user_controller.ex:71 lib/pleroma/web/ostatus/ostatus_controller.ex:143 #, elixir-format msgid "Not found" -msgstr "" +msgstr "ๆœชๆ‰พๅˆฐ" #: lib/pleroma/web/common_api/common_api.ex:331 #, elixir-format msgid "Poll's author can't vote" -msgstr "" +msgstr "ๆŠ•็ฅจ็š„ๅ‘่ตท่€…ไธ่ƒฝๆŠ•็ฅจ" #: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 #: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 @@ -272,39 +272,39 @@ msgstr "" #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 #, elixir-format msgid "Record not found" -msgstr "" +msgstr "ๆœชๆ‰พๅˆฐ่ฏฅ่ฎฐๅฝ•" #: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:35 #: lib/pleroma/web/feed/user_controller.ex:77 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:36 #: lib/pleroma/web/ostatus/ostatus_controller.ex:149 #, elixir-format msgid "Something went wrong" -msgstr "" +msgstr "ๅ‘็”Ÿไบ†ไธ€ไบ›้”™่ฏฏ" #: lib/pleroma/web/common_api/activity_draft.ex:107 #, elixir-format msgid "The message visibility must be direct" -msgstr "" +msgstr "่ฏฅๆถˆๆฏๅฟ…้กปไธบ็งไฟก" #: lib/pleroma/web/common_api/utils.ex:573 #, elixir-format msgid "The status is over the character limit" -msgstr "" +msgstr "็Šถๆ€่ถ…่ฟ‡ไบ†ๅญ—็ฌฆๆ•ฐ้™ๅˆถ" #: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 #, elixir-format msgid "This resource requires authentication." -msgstr "" +msgstr "่ฏฅ่ต„ๆบ้œ€่ฆ่ฎค่ฏใ€‚" #: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 -#, elixir-format +#, elixir-format, fuzzy msgid "Throttled" -msgstr "" +msgstr "่Š‚ๆตไบ†" #: lib/pleroma/web/common_api/common_api.ex:356 #, elixir-format msgid "Too many choices" -msgstr "" +msgstr "ๅคชๅคš้€‰้กน" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:443 #, elixir-format @@ -314,101 +314,101 @@ msgstr "" #: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:485 #, elixir-format msgid "You can't revoke your own admin status." -msgstr "" +msgstr "ๆ‚จไธ่ƒฝๆ’คๆถˆ่‡ชๅทฑ็š„็ฎก็†ๅ‘˜ๆƒ้™ใ€‚" #: lib/pleroma/web/oauth/oauth_controller.ex:221 #: lib/pleroma/web/oauth/oauth_controller.ex:308 #, elixir-format msgid "Your account is currently disabled" -msgstr "" +msgstr "ๆ‚จ็š„่ดฆๆˆทๅทฒ่ขซ็ฆ็”จ" #: lib/pleroma/web/oauth/oauth_controller.ex:183 #: lib/pleroma/web/oauth/oauth_controller.ex:331 #, elixir-format msgid "Your login is missing a confirmed e-mail address" -msgstr "" +msgstr "ๆ‚จ็š„่ดฆๆˆท็ผบๅฐ‘ๅทฒ่ฎค่ฏ็š„ e-mail ๅœฐๅ€" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:390 #, elixir-format msgid "can't read inbox of %{nickname} as %{as_nickname}" -msgstr "" +msgstr "ๆ— ๆณ•ไปฅ %{as_nickname} ่ฏปๅ– %{nickname} ็š„ๆ”ถไปถ็ฎฑ" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:473 #, elixir-format msgid "can't update outbox of %{nickname} as %{as_nickname}" -msgstr "" +msgstr "ๆ— ๆณ•ไปฅ %{as_nickname} ๆ›ดๆ–ฐ %{nickname} ็š„ๅ‡บไปถ็ฎฑ" #: lib/pleroma/web/common_api/common_api.ex:471 #, elixir-format msgid "conversation is already muted" -msgstr "" +msgstr "ๅฏน่ฏๅทฒ็ป่ขซ้™้Ÿณ" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:314 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:492 #, elixir-format msgid "error" -msgstr "" +msgstr "้”™่ฏฏ" #: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:32 #, elixir-format msgid "mascots can only be images" -msgstr "" +msgstr "ๅ‰็ฅฅ็‰ฉๅช่ƒฝๆ˜ฏๅ›พ็‰‡" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:62 #, elixir-format msgid "not found" -msgstr "" +msgstr "ๆœชๆ‰พๅˆฐ" #: lib/pleroma/web/oauth/oauth_controller.ex:394 #, elixir-format msgid "Bad OAuth request." -msgstr "" +msgstr "้”™่ฏฏ็š„ OAuth ่ฏทๆฑ‚ใ€‚" #: lib/pleroma/web/twitter_api/twitter_api.ex:115 #, elixir-format msgid "CAPTCHA already used" -msgstr "" +msgstr "้ชŒ่ฏ็ ๅทฒ่ขซไฝฟ็”จ" #: lib/pleroma/web/twitter_api/twitter_api.ex:112 #, elixir-format msgid "CAPTCHA expired" -msgstr "" +msgstr "้ชŒ่ฏ็ ๅทฒ่ฟ‡ๆœŸ" #: lib/pleroma/plugs/uploaded_media.ex:57 #, elixir-format msgid "Failed" -msgstr "" +msgstr "ๅคฑ่ดฅ" #: lib/pleroma/web/oauth/oauth_controller.ex:410 -#, elixir-format +#, elixir-format, fuzzy msgid "Failed to authenticate: %{message}." -msgstr "" +msgstr "่ฎค่ฏๅคฑ่ดฅ๏ผš%{message}ใ€‚" #: lib/pleroma/web/oauth/oauth_controller.ex:441 #, elixir-format msgid "Failed to set up user account." -msgstr "" +msgstr "ๅปบ็ซ‹็”จๆˆทๅธๅทๅคฑ่ดฅใ€‚" #: lib/pleroma/plugs/oauth_scopes_plug.ex:38 #, elixir-format msgid "Insufficient permissions: %{permissions}." -msgstr "" +msgstr "ๆƒ้™ไธ่ถณ๏ผš%{permissions}ใ€‚" #: lib/pleroma/plugs/uploaded_media.ex:104 #, elixir-format msgid "Internal Error" -msgstr "" +msgstr "ๅ†…้ƒจ้”™่ฏฏ" #: lib/pleroma/web/oauth/fallback_controller.ex:22 #: lib/pleroma/web/oauth/fallback_controller.ex:29 #, elixir-format msgid "Invalid Username/Password" -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„็”จๆˆทๅ/ๅฏ†็ " #: lib/pleroma/web/twitter_api/twitter_api.ex:118 -#, elixir-format +#, elixir-format, fuzzy msgid "Invalid answer data" -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„ๅ›ž็ญ”ๆ•ฐๆฎ" #: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:33 #, elixir-format @@ -418,12 +418,12 @@ msgstr "" #: lib/pleroma/web/oauth/oauth_controller.ex:172 #, elixir-format msgid "This action is outside the authorized scopes" -msgstr "" +msgstr "ๆญคๆ“ไฝœๅœจ่ฎธๅฏ่Œƒๅ›ดไปฅๅค–" #: lib/pleroma/web/oauth/fallback_controller.ex:14 #, elixir-format msgid "Unknown error, please check the details and try again." -msgstr "" +msgstr "ๆœช็Ÿฅ้”™่ฏฏ๏ผŒ่ฏทๆฃ€ๆŸฅๅนถ้‡่ฏ•ใ€‚" #: lib/pleroma/web/oauth/oauth_controller.ex:119 #: lib/pleroma/web/oauth/oauth_controller.ex:158 @@ -434,53 +434,53 @@ msgstr "" #: lib/pleroma/web/oauth/oauth_controller.ex:390 #, elixir-format msgid "Unsupported OAuth provider: %{provider}." -msgstr "" +msgstr "ไธๆ”ฏๆŒ็š„ OAuth ๆไพ›่€…๏ผš%{provider}ใ€‚" #: lib/pleroma/uploaders/uploader.ex:72 -#, elixir-format +#, elixir-format, fuzzy msgid "Uploader callback timeout" -msgstr "" +msgstr "ไธŠไผ ๅ›žๅค่ถ…ๆ—ถ" #: lib/pleroma/web/uploader_controller.ex:23 #, elixir-format msgid "bad request" -msgstr "" +msgstr "้”™่ฏฏ็š„่ฏทๆฑ‚" #: lib/pleroma/web/twitter_api/twitter_api.ex:103 #, elixir-format msgid "CAPTCHA Error" -msgstr "" +msgstr "้ชŒ่ฏ็ ้”™่ฏฏ" #: lib/pleroma/web/common_api/common_api.ex:290 -#, elixir-format +#, elixir-format, fuzzy msgid "Could not add reaction emoji" -msgstr "" +msgstr "ๆ— ๆณ•ๆทปๅŠ ่กจๆƒ…ๅๅบ”" #: lib/pleroma/web/common_api/common_api.ex:301 #, elixir-format msgid "Could not remove reaction emoji" -msgstr "" +msgstr "ๆ— ๆณ•็งป้™ค่กจๆƒ…ๅๅบ”" #: lib/pleroma/web/twitter_api/twitter_api.ex:129 #, elixir-format msgid "Invalid CAPTCHA (Missing parameter: %{name})" -msgstr "" +msgstr "ๆ— ๆ•ˆ็š„้ชŒ่ฏ็ ๏ผˆ็ผบๅฐ‘ๅ‚ๆ•ฐ๏ผš%{name}๏ผ‰" #: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 #, elixir-format msgid "List not found" -msgstr "" +msgstr "ๆœชๆ‰พๅˆฐๅˆ—่กจ" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:123 #, elixir-format msgid "Missing parameter: %{name}" -msgstr "" +msgstr "็ผบๅฐ‘ๅ‚ๆ•ฐ๏ผš%{name}" #: lib/pleroma/web/oauth/oauth_controller.ex:210 #: lib/pleroma/web/oauth/oauth_controller.ex:321 #, elixir-format msgid "Password reset is required" -msgstr "" +msgstr "้œ€่ฆ้‡็ฝฎๅฏ†็ " #: lib/pleroma/tests/auth_test_controller.ex:9 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:6 @@ -520,61 +520,61 @@ msgid "Security violation: OAuth scopes check was neither handled nor explicitly msgstr "" #: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 -#, elixir-format +#, elixir-format, fuzzy msgid "Two-factor authentication enabled, you must use a access token." -msgstr "" +msgstr "ๅทฒๅฏ็”จไธคๅ› ็ด ้ชŒ่ฏ๏ผŒๆ‚จ้œ€่ฆไฝฟ็”จ่ฎฟ้—ฎไปค็‰Œใ€‚" #: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:210 #, elixir-format msgid "Unexpected error occurred while adding file to pack." -msgstr "" +msgstr "ๅ‘่กจๆƒ…ๅŒ…ๆทปๅŠ ๆ–‡ไปถๆ—ถๅ‘็”Ÿไบ†ๆฒกๆœ‰้ข„ๆ–™ๅˆฐ็š„้”™่ฏฏใ€‚" #: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:138 #, elixir-format msgid "Unexpected error occurred while creating pack." -msgstr "" +msgstr "ๅˆ›ๅปบ่กจๆƒ…ๅŒ…ๆ—ถๅ‘็”Ÿไบ†ๆฒกๆœ‰้ข„ๆ–™ๅˆฐ็š„้”™่ฏฏใ€‚" #: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:278 #, elixir-format msgid "Unexpected error occurred while removing file from pack." -msgstr "" +msgstr "ไปŽ่กจๆƒ…ๅŒ…็งป้™คๆ–‡ไปถๆ—ถๅ‘็”Ÿไบ†ๆฒกๆœ‰้ข„ๆ–™ๅˆฐ็š„้”™่ฏฏใ€‚" #: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:250 #, elixir-format msgid "Unexpected error occurred while updating file in pack." -msgstr "" +msgstr "ๆ›ดๆ–ฐ่กจๆƒ…ๅŒ…ๅ†…็š„ๆ–‡ไปถๆ—ถๅ‘็”Ÿไบ†ๆฒกๆœ‰้ข„ๆ–™ๅˆฐ็š„้”™่ฏฏใ€‚" #: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:179 #, elixir-format msgid "Unexpected error occurred while updating pack metadata." -msgstr "" +msgstr "ๆ›ดๆ–ฐ่กจๆƒ…ๅŒ…ๅ…ƒๆ•ฐๆฎๆ—ถๅ‘็”Ÿไบ†ๆฒกๆœ‰้ข„ๆ–™ๅˆฐ็š„้”™่ฏฏใ€‚" #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 -#, elixir-format +#, elixir-format, fuzzy msgid "Web push subscription is disabled on this Pleroma instance" -msgstr "" +msgstr "ๆญค Pleroma ๅฎžไพ‹็ฆ็”จไบ†็ฝ‘้กตๆŽจ้€่ฎข้˜…" #: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:451 #, elixir-format msgid "You can't revoke your own admin/moderator status." -msgstr "" +msgstr "ๆ‚จไธ่ƒฝๆ’คๆถˆ่‡ชๅทฑ็š„็ฎก็†ๅ‘˜ๆƒ้™ใ€‚" #: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:126 #, elixir-format msgid "authorization required for timeline view" -msgstr "" +msgstr "ๆต่งˆๆ—ถ้—ด็บฟ้œ€่ฆ่ฎค่ฏ" #: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:24 #, elixir-format msgid "Access denied" -msgstr "" +msgstr "ๆ‹’็ป่ฎฟ้—ฎ" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:282 #, elixir-format msgid "This API requires an authenticated user" -msgstr "" +msgstr "ๆญค API ้œ€่ฆๅทฒ่ฎค่ฏ็š„็”จๆˆท" #: lib/pleroma/plugs/user_is_admin_plug.ex:21 #, elixir-format msgid "User is not an admin." -msgstr "" +msgstr "่ฏฅ็”จๆˆทไธๆ˜ฏ็ฎก็†ๅ‘˜ใ€‚" diff --git a/priv/repo/migrations/20200831114918_remove_unread_conversation_count_from_user.exs b/priv/repo/migrations/20200831114918_remove_unread_conversation_count_from_user.exs new file mode 100644 index 000000000..b7bdb9166 --- /dev/null +++ b/priv/repo/migrations/20200831114918_remove_unread_conversation_count_from_user.exs @@ -0,0 +1,38 @@ +defmodule Pleroma.Repo.Migrations.RemoveUnreadConversationCountFromUser do + use Ecto.Migration + import Ecto.Query + alias Pleroma.Repo + + def up do + alter table(:users) do + remove_if_exists(:unread_conversation_count, :integer) + end + end + + def down do + alter table(:users) do + add_if_not_exists(:unread_conversation_count, :integer, default: 0) + end + + flush() + recalc_unread_conversation_count() + end + + defp recalc_unread_conversation_count do + participations_subquery = + from( + p in "conversation_participations", + where: p.read == false, + group_by: p.user_id, + select: %{user_id: p.user_id, unread_conversation_count: count(p.id)} + ) + + from( + u in "users", + join: p in subquery(participations_subquery), + on: p.user_id == u.id, + update: [set: [unread_conversation_count: p.unread_conversation_count]] + ) + |> Repo.update_all([]) + end +end diff --git a/priv/repo/migrations/20200831115854_add_unread_index_to_conversation_participation.exs b/priv/repo/migrations/20200831115854_add_unread_index_to_conversation_participation.exs new file mode 100644 index 000000000..68771c655 --- /dev/null +++ b/priv/repo/migrations/20200831115854_add_unread_index_to_conversation_participation.exs @@ -0,0 +1,12 @@ +defmodule Pleroma.Repo.Migrations.AddUnreadIndexToConversationParticipation do + use Ecto.Migration + + def change do + create( + index(:conversation_participations, [:user_id], + where: "read = false", + name: "unread_conversation_participation_count_index" + ) + ) + end +end diff --git a/priv/repo/migrations/20200831152600_add_pleroma_report_to_enum_for_notifications.exs b/priv/repo/migrations/20200831152600_add_pleroma_report_to_enum_for_notifications.exs new file mode 100644 index 000000000..01fb90459 --- /dev/null +++ b/priv/repo/migrations/20200831152600_add_pleroma_report_to_enum_for_notifications.exs @@ -0,0 +1,48 @@ +defmodule Pleroma.Repo.Migrations.AddPleromaReportTypeToEnumForNotifications do + use Ecto.Migration + + @disable_ddl_transaction true + + def up do + """ + alter type notification_type add value 'pleroma:report' + """ + |> execute() + end + + def down do + alter table(:notifications) do + modify(:type, :string) + end + + """ + delete from notifications where type = 'pleroma:report' + """ + |> execute() + + """ + drop type if exists notification_type + """ + |> execute() + + """ + create type notification_type as enum ( + 'follow', + 'follow_request', + 'mention', + 'move', + 'pleroma:emoji_reaction', + 'pleroma:chat_mention', + 'reblog', + 'favourite' + ) + """ + |> execute() + + """ + alter table notifications + alter column type type notification_type using (type::notification_type) + """ + |> execute() + end +end diff --git a/priv/repo/migrations/20200831192323_create_backups.exs b/priv/repo/migrations/20200831192323_create_backups.exs new file mode 100644 index 000000000..3ac5889e2 --- /dev/null +++ b/priv/repo/migrations/20200831192323_create_backups.exs @@ -0,0 +1,17 @@ +defmodule Pleroma.Repo.Migrations.CreateBackups do + use Ecto.Migration + + def change do + create_if_not_exists table(:backups) do + add(:user_id, references(:users, type: :uuid, on_delete: :delete_all)) + add(:file_name, :string, null: false) + add(:content_type, :string, null: false) + add(:processed, :boolean, null: false, default: false) + add(:file_size, :bigint) + + timestamps() + end + + create_if_not_exists(index(:backups, [:user_id])) + end +end diff --git a/priv/repo/migrations/20200915095704_remove_background_jobs.exs b/priv/repo/migrations/20200915095704_remove_background_jobs.exs new file mode 100644 index 000000000..9785bfb8a --- /dev/null +++ b/priv/repo/migrations/20200915095704_remove_background_jobs.exs @@ -0,0 +1,22 @@ +defmodule Pleroma.Repo.Migrations.RemoveBackgroundJobs do + use Ecto.Migration + + import Ecto.Query, only: [from: 2] + + def up do + from(j in "oban_jobs", + where: + j.queue == ^"background" and + fragment("?->>'op'", j.args) in ^[ + "fetch_data_for_activity", + "media_proxy_prefetch", + "media_proxy_preload" + ] and + j.worker == ^"Pleroma.Workers.BackgroundWorker", + select: [:id] + ) + |> Pleroma.Repo.delete_all() + end + + def down, do: :ok +end diff --git a/priv/repo/migrations/20201013144052_refactor_discoverable_user_field.exs b/priv/repo/migrations/20201013144052_refactor_discoverable_user_field.exs new file mode 100644 index 000000000..3fdc190cc --- /dev/null +++ b/priv/repo/migrations/20201013144052_refactor_discoverable_user_field.exs @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.RefactorDiscoverableUserField do + use Ecto.Migration + + def up do + execute("ALTER TABLE users RENAME COLUMN discoverable TO is_discoverable;") + end + + def down do + execute("ALTER TABLE users RENAME COLUMN is_discoverable TO discoverable;") + end +end diff --git a/priv/repo/migrations/20201113060459_remove_purge_expired_activity_worker_from_oban_config.exs b/priv/repo/migrations/20201113060459_remove_purge_expired_activity_worker_from_oban_config.exs new file mode 100644 index 000000000..fe31f4442 --- /dev/null +++ b/priv/repo/migrations/20201113060459_remove_purge_expired_activity_worker_from_oban_config.exs @@ -0,0 +1,19 @@ +defmodule Pleroma.Repo.Migrations.RemovePurgeExpiredActivityWorkerFromObanConfig do + use Ecto.Migration + + def change do + with %Pleroma.ConfigDB{} = config <- + Pleroma.ConfigDB.get_by_params(%{group: :pleroma, key: Oban}), + crontab when is_list(crontab) <- config.value[:crontab], + index when is_integer(index) <- + Enum.find_index(crontab, fn {_, worker} -> + worker == Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker + end) do + updated_value = Keyword.put(config.value, :crontab, List.delete_at(crontab, index)) + + config + |> Ecto.Changeset.change(value: updated_value) + |> Pleroma.Repo.update() + end + end +end diff --git a/priv/repo/migrations/20201217172858_data_migration_prolong_o_auth_tokens_valid_until.exs b/priv/repo/migrations/20201217172858_data_migration_prolong_o_auth_tokens_valid_until.exs new file mode 100644 index 000000000..560cc7447 --- /dev/null +++ b/priv/repo/migrations/20201217172858_data_migration_prolong_o_auth_tokens_valid_until.exs @@ -0,0 +1,13 @@ +defmodule Pleroma.Repo.Migrations.DataMigrationProlongOAuthTokensValidUntil do + use Ecto.Migration + + def up do + expires_in = Pleroma.Config.get!([:oauth2, :token_expires_in]) + valid_until = NaiveDateTime.add(NaiveDateTime.utc_now(), expires_in, :second) + execute("update oauth_tokens set valid_until = '#{valid_until}'") + end + + def down do + :noop + end +end diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index ea0480dcd..7b06994de 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -47,6 +47,11 @@ defmodule Pleroma.HTML.Scrubber.Default do Meta.allow_tag_with_these_attributes(:strong, []) Meta.allow_tag_with_these_attributes(:sub, []) Meta.allow_tag_with_these_attributes(:sup, []) + Meta.allow_tag_with_these_attributes(:ruby, []) + Meta.allow_tag_with_these_attributes(:rb, []) + Meta.allow_tag_with_these_attributes(:rp, []) + Meta.allow_tag_with_these_attributes(:rt, []) + Meta.allow_tag_with_these_attributes(:rtc, []) Meta.allow_tag_with_these_attributes(:u, []) Meta.allow_tag_with_these_attributes(:ul, []) diff --git a/priv/static/adminfe/chunk-03c5.f59788cf.css b/priv/static/adminfe/chunk-03c5.e6a0e2d0.css similarity index 100% rename from priv/static/adminfe/chunk-03c5.f59788cf.css rename to priv/static/adminfe/chunk-03c5.e6a0e2d0.css diff --git a/priv/static/adminfe/chunk-9d55.e2cb1409.css b/priv/static/adminfe/chunk-0492.15b0611f.css similarity index 100% rename from priv/static/adminfe/chunk-9d55.e2cb1409.css rename to priv/static/adminfe/chunk-0492.15b0611f.css diff --git a/priv/static/adminfe/chunk-342d.e342722b.css b/priv/static/adminfe/chunk-04b0.7e25cd78.css similarity index 97% rename from priv/static/adminfe/chunk-342d.e342722b.css rename to priv/static/adminfe/chunk-04b0.7e25cd78.css index b0fd8dcb3..8dfdc0dcf 100644 Binary files a/priv/static/adminfe/chunk-342d.e342722b.css and b/priv/static/adminfe/chunk-04b0.7e25cd78.css differ diff --git a/priv/static/adminfe/chunk-546f.692d1ab2.css b/priv/static/adminfe/chunk-0537.cd83e5d6.css similarity index 100% rename from priv/static/adminfe/chunk-546f.692d1ab2.css rename to priv/static/adminfe/chunk-0537.cd83e5d6.css diff --git a/priv/static/adminfe/chunk-170f.fea927c5.css b/priv/static/adminfe/chunk-170f.fea927c5.css new file mode 100644 index 000000000..a4ab52d51 Binary files /dev/null and b/priv/static/adminfe/chunk-170f.fea927c5.css differ diff --git a/priv/static/adminfe/chunk-176e.a3c8376d.css b/priv/static/adminfe/chunk-176e.d9a630b2.css similarity index 100% rename from priv/static/adminfe/chunk-176e.a3c8376d.css rename to priv/static/adminfe/chunk-176e.d9a630b2.css diff --git a/priv/static/adminfe/chunk-521f.b745ee5d.css b/priv/static/adminfe/chunk-1944.731ba892.css similarity index 62% rename from priv/static/adminfe/chunk-521f.b745ee5d.css rename to priv/static/adminfe/chunk-1944.731ba892.css index 7e8ffb651..6392d8e75 100644 Binary files a/priv/static/adminfe/chunk-521f.b745ee5d.css and b/priv/static/adminfe/chunk-1944.731ba892.css differ diff --git a/priv/static/adminfe/chunk-654d.94689c39.css b/priv/static/adminfe/chunk-654d.94689c39.css deleted file mode 100644 index 483d88545..000000000 Binary files a/priv/static/adminfe/chunk-654d.94689c39.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-68ea9.dac85813.css b/priv/static/adminfe/chunk-68ea9.8331e95e.css similarity index 100% rename from priv/static/adminfe/chunk-68ea9.dac85813.css rename to priv/static/adminfe/chunk-68ea9.8331e95e.css diff --git a/priv/static/adminfe/chunk-6e81.1c0f2da2.css b/priv/static/adminfe/chunk-6e81.559b76f9.css similarity index 100% rename from priv/static/adminfe/chunk-6e81.1c0f2da2.css rename to priv/static/adminfe/chunk-6e81.559b76f9.css diff --git a/priv/static/adminfe/chunk-7968.283bc086.css b/priv/static/adminfe/chunk-7968.283bc086.css new file mode 100644 index 000000000..5d9863d3a Binary files /dev/null and b/priv/static/adminfe/chunk-7968.283bc086.css differ diff --git a/priv/static/adminfe/chunk-7c6b.365cbeda.css b/priv/static/adminfe/chunk-7c6b.b633878a.css similarity index 100% rename from priv/static/adminfe/chunk-7c6b.365cbeda.css rename to priv/static/adminfe/chunk-7c6b.b633878a.css diff --git a/priv/static/adminfe/chunk-850d.cc4f0ac6.css b/priv/static/adminfe/chunk-850d.cc4f0ac6.css deleted file mode 100644 index 1cb2ead63..000000000 Binary files a/priv/static/adminfe/chunk-850d.cc4f0ac6.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-8fbb.dd321643.css b/priv/static/adminfe/chunk-8fbb.dd321643.css new file mode 100644 index 000000000..f50d974bd Binary files /dev/null and b/priv/static/adminfe/chunk-8fbb.dd321643.css differ diff --git a/priv/static/adminfe/chunk-3365.201aa8e6.css b/priv/static/adminfe/chunk-ad1e.1a3c5339.css similarity index 100% rename from priv/static/adminfe/chunk-3365.201aa8e6.css rename to priv/static/adminfe/chunk-ad1e.1a3c5339.css diff --git a/priv/static/adminfe/chunk-commons.c0eb3eb7.css b/priv/static/adminfe/chunk-commons.f7c3d390.css similarity index 100% rename from priv/static/adminfe/chunk-commons.c0eb3eb7.css rename to priv/static/adminfe/chunk-commons.f7c3d390.css diff --git a/priv/static/adminfe/chunk-d34d.b0dd6fb4.css b/priv/static/adminfe/chunk-e660.9e75af5b.css similarity index 100% rename from priv/static/adminfe/chunk-d34d.b0dd6fb4.css rename to priv/static/adminfe/chunk-e660.9e75af5b.css diff --git a/priv/static/adminfe/chunk-f364.6b5f3f0d.css b/priv/static/adminfe/chunk-f364.6b5f3f0d.css new file mode 100644 index 000000000..ec665da84 Binary files /dev/null and b/priv/static/adminfe/chunk-f364.6b5f3f0d.css differ diff --git a/priv/static/adminfe/chunk-f625.25a6a4ae.css b/priv/static/adminfe/chunk-f625.bcd0ea3b.css similarity index 100% rename from priv/static/adminfe/chunk-f625.25a6a4ae.css rename to priv/static/adminfe/chunk-f625.bcd0ea3b.css diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index d6b9b22b8..e6af40e97 100644 --- a/priv/static/adminfe/index.html +++ b/priv/static/adminfe/index.html @@ -1 +1 @@ -Admin FE
    \ No newline at end of file +Admin FE
    \ No newline at end of file diff --git a/priv/static/adminfe/static/js/app.69891fda.js b/priv/static/adminfe/static/js/app.69891fda.js deleted file mode 100644 index 3d04d9273..000000000 Binary files a/priv/static/adminfe/static/js/app.69891fda.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.69891fda.js.map b/priv/static/adminfe/static/js/app.69891fda.js.map deleted file mode 100644 index 0131793e9..000000000 Binary files a/priv/static/adminfe/static/js/app.69891fda.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.c67f9a2f.js b/priv/static/adminfe/static/js/app.c67f9a2f.js new file mode 100644 index 000000000..65f9d4a29 Binary files /dev/null and b/priv/static/adminfe/static/js/app.c67f9a2f.js differ diff --git a/priv/static/adminfe/static/js/app.c67f9a2f.js.map b/priv/static/adminfe/static/js/app.c67f9a2f.js.map new file mode 100644 index 000000000..41b4375aa Binary files /dev/null and b/priv/static/adminfe/static/js/app.c67f9a2f.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-03c5.1c694c49.js b/priv/static/adminfe/static/js/chunk-03c5.6de0c4c7.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-03c5.1c694c49.js rename to priv/static/adminfe/static/js/chunk-03c5.6de0c4c7.js index b4601abae..a89c65572 100644 Binary files a/priv/static/adminfe/static/js/chunk-03c5.1c694c49.js and b/priv/static/adminfe/static/js/chunk-03c5.6de0c4c7.js differ diff --git a/priv/static/adminfe/static/js/chunk-03c5.1c694c49.js.map b/priv/static/adminfe/static/js/chunk-03c5.6de0c4c7.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-03c5.1c694c49.js.map rename to priv/static/adminfe/static/js/chunk-03c5.6de0c4c7.js.map index 193c65bb1..963ff6dee 100644 Binary files a/priv/static/adminfe/static/js/chunk-03c5.1c694c49.js.map and b/priv/static/adminfe/static/js/chunk-03c5.6de0c4c7.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0492.47abe1dc.js b/priv/static/adminfe/static/js/chunk-0492.47abe1dc.js new file mode 100644 index 000000000..243ecde70 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0492.47abe1dc.js differ diff --git a/priv/static/adminfe/static/js/chunk-0492.47abe1dc.js.map b/priv/static/adminfe/static/js/chunk-0492.47abe1dc.js.map new file mode 100644 index 000000000..f5e0d9ebc Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0492.47abe1dc.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-04b0.90c6d24c.js b/priv/static/adminfe/static/js/chunk-04b0.90c6d24c.js new file mode 100644 index 000000000..9d0352814 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-04b0.90c6d24c.js differ diff --git a/priv/static/adminfe/static/js/chunk-04b0.90c6d24c.js.map b/priv/static/adminfe/static/js/chunk-04b0.90c6d24c.js.map new file mode 100644 index 000000000..a9bee3721 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-04b0.90c6d24c.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-546f.81668ba7.js b/priv/static/adminfe/static/js/chunk-0537.74db16b0.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-546f.81668ba7.js rename to priv/static/adminfe/static/js/chunk-0537.74db16b0.js index 252991021..35231e562 100644 Binary files a/priv/static/adminfe/static/js/chunk-546f.81668ba7.js and b/priv/static/adminfe/static/js/chunk-0537.74db16b0.js differ diff --git a/priv/static/adminfe/static/js/chunk-546f.81668ba7.js.map b/priv/static/adminfe/static/js/chunk-0537.74db16b0.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-546f.81668ba7.js.map rename to priv/static/adminfe/static/js/chunk-0537.74db16b0.js.map index 6a9d2a8dc..fa87bd76d 100644 Binary files a/priv/static/adminfe/static/js/chunk-546f.81668ba7.js.map and b/priv/static/adminfe/static/js/chunk-0537.74db16b0.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-170f.e1d6aac3.js b/priv/static/adminfe/static/js/chunk-170f.e1d6aac3.js new file mode 100644 index 000000000..d40dc29bd Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-170f.e1d6aac3.js differ diff --git a/priv/static/adminfe/static/js/chunk-170f.e1d6aac3.js.map b/priv/static/adminfe/static/js/chunk-170f.e1d6aac3.js.map new file mode 100644 index 000000000..91d3bc70d Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-170f.e1d6aac3.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-176e.be050aba.js b/priv/static/adminfe/static/js/chunk-176e.f64cb745.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-176e.be050aba.js rename to priv/static/adminfe/static/js/chunk-176e.f64cb745.js index cab83489b..dd60693da 100644 Binary files a/priv/static/adminfe/static/js/chunk-176e.be050aba.js and b/priv/static/adminfe/static/js/chunk-176e.f64cb745.js differ diff --git a/priv/static/adminfe/static/js/chunk-176e.be050aba.js.map b/priv/static/adminfe/static/js/chunk-176e.f64cb745.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-176e.be050aba.js.map rename to priv/static/adminfe/static/js/chunk-176e.f64cb745.js.map index bff959cd6..9ee6aa6e4 100644 Binary files a/priv/static/adminfe/static/js/chunk-176e.be050aba.js.map and b/priv/static/adminfe/static/js/chunk-176e.f64cb745.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-1944.7bed0c4b.js b/priv/static/adminfe/static/js/chunk-1944.7bed0c4b.js new file mode 100644 index 000000000..87590c6ce Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-1944.7bed0c4b.js differ diff --git a/priv/static/adminfe/static/js/chunk-1944.7bed0c4b.js.map b/priv/static/adminfe/static/js/chunk-1944.7bed0c4b.js.map new file mode 100644 index 000000000..23229293e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-1944.7bed0c4b.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-342d.479e01dd.js b/priv/static/adminfe/static/js/chunk-342d.479e01dd.js deleted file mode 100644 index 5ee311c4a..000000000 Binary files a/priv/static/adminfe/static/js/chunk-342d.479e01dd.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-342d.479e01dd.js.map b/priv/static/adminfe/static/js/chunk-342d.479e01dd.js.map deleted file mode 100644 index b73bbb0aa..000000000 Binary files a/priv/static/adminfe/static/js/chunk-342d.479e01dd.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-521f.748b331d.js b/priv/static/adminfe/static/js/chunk-521f.748b331d.js deleted file mode 100644 index 570dab224..000000000 Binary files a/priv/static/adminfe/static/js/chunk-521f.748b331d.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-521f.748b331d.js.map b/priv/static/adminfe/static/js/chunk-521f.748b331d.js.map deleted file mode 100644 index 3380bbbd5..000000000 Binary files a/priv/static/adminfe/static/js/chunk-521f.748b331d.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-654d.653b067f.js b/priv/static/adminfe/static/js/chunk-654d.653b067f.js deleted file mode 100644 index 209873ec1..000000000 Binary files a/priv/static/adminfe/static/js/chunk-654d.653b067f.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-654d.653b067f.js.map b/priv/static/adminfe/static/js/chunk-654d.653b067f.js.map deleted file mode 100644 index 72aca0e98..000000000 Binary files a/priv/static/adminfe/static/js/chunk-654d.653b067f.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-68ea9.9821cd6a.js b/priv/static/adminfe/static/js/chunk-68ea9.2b2877d5.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-68ea9.9821cd6a.js rename to priv/static/adminfe/static/js/chunk-68ea9.2b2877d5.js index 02091ed84..60056454d 100644 Binary files a/priv/static/adminfe/static/js/chunk-68ea9.9821cd6a.js and b/priv/static/adminfe/static/js/chunk-68ea9.2b2877d5.js differ diff --git a/priv/static/adminfe/static/js/chunk-68ea9.9821cd6a.js.map b/priv/static/adminfe/static/js/chunk-68ea9.2b2877d5.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-68ea9.9821cd6a.js.map rename to priv/static/adminfe/static/js/chunk-68ea9.2b2877d5.js.map index 019cede66..9e26519c3 100644 Binary files a/priv/static/adminfe/static/js/chunk-68ea9.9821cd6a.js.map and b/priv/static/adminfe/static/js/chunk-68ea9.2b2877d5.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.ebe9039f.js b/priv/static/adminfe/static/js/chunk-6e81.afade883.js similarity index 97% rename from priv/static/adminfe/static/js/chunk-6e81.ebe9039f.js rename to priv/static/adminfe/static/js/chunk-6e81.afade883.js index cd79db1d3..3b5dd6c5c 100644 Binary files a/priv/static/adminfe/static/js/chunk-6e81.ebe9039f.js and b/priv/static/adminfe/static/js/chunk-6e81.afade883.js differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.ebe9039f.js.map b/priv/static/adminfe/static/js/chunk-6e81.afade883.js.map similarity index 98% rename from priv/static/adminfe/static/js/chunk-6e81.ebe9039f.js.map rename to priv/static/adminfe/static/js/chunk-6e81.afade883.js.map index 10b437760..a0f7fca19 100644 Binary files a/priv/static/adminfe/static/js/chunk-6e81.ebe9039f.js.map and b/priv/static/adminfe/static/js/chunk-6e81.afade883.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7968.f51e3292.js b/priv/static/adminfe/static/js/chunk-7968.f51e3292.js new file mode 100644 index 000000000..dc981706f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7968.f51e3292.js differ diff --git a/priv/static/adminfe/static/js/chunk-7968.f51e3292.js.map b/priv/static/adminfe/static/js/chunk-7968.f51e3292.js.map new file mode 100644 index 000000000..c2f0726b7 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7968.f51e3292.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.56a14571.js b/priv/static/adminfe/static/js/chunk-7c6b.34152862.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-7c6b.56a14571.js rename to priv/static/adminfe/static/js/chunk-7c6b.34152862.js index df9b7d6ac..27d57d3ff 100644 Binary files a/priv/static/adminfe/static/js/chunk-7c6b.56a14571.js and b/priv/static/adminfe/static/js/chunk-7c6b.34152862.js differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.56a14571.js.map b/priv/static/adminfe/static/js/chunk-7c6b.34152862.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-7c6b.56a14571.js.map rename to priv/static/adminfe/static/js/chunk-7c6b.34152862.js.map index 6584ba082..78026f5f4 100644 Binary files a/priv/static/adminfe/static/js/chunk-7c6b.56a14571.js.map and b/priv/static/adminfe/static/js/chunk-7c6b.34152862.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-850d.3e6102c2.js b/priv/static/adminfe/static/js/chunk-850d.3e6102c2.js deleted file mode 100644 index a2c2df2e7..000000000 Binary files a/priv/static/adminfe/static/js/chunk-850d.3e6102c2.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-850d.3e6102c2.js.map b/priv/static/adminfe/static/js/chunk-850d.3e6102c2.js.map deleted file mode 100644 index 7f7718547..000000000 Binary files a/priv/static/adminfe/static/js/chunk-850d.3e6102c2.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-8fbb.c847ce9d.js b/priv/static/adminfe/static/js/chunk-8fbb.c847ce9d.js new file mode 100644 index 000000000..74ffe9194 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-8fbb.c847ce9d.js differ diff --git a/priv/static/adminfe/static/js/chunk-8fbb.c847ce9d.js.map b/priv/static/adminfe/static/js/chunk-8fbb.c847ce9d.js.map new file mode 100644 index 000000000..b3c3b5fe8 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-8fbb.c847ce9d.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-9d55.7af22f45.js b/priv/static/adminfe/static/js/chunk-9d55.7af22f45.js deleted file mode 100644 index 89d35af79..000000000 Binary files a/priv/static/adminfe/static/js/chunk-9d55.7af22f45.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-9d55.7af22f45.js.map b/priv/static/adminfe/static/js/chunk-9d55.7af22f45.js.map deleted file mode 100644 index fa8694b8e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-9d55.7af22f45.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-3365.b73c30a8.js b/priv/static/adminfe/static/js/chunk-ad1e.eba9db26.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-3365.b73c30a8.js rename to priv/static/adminfe/static/js/chunk-ad1e.eba9db26.js index 421bf2a99..82ddd4df2 100644 Binary files a/priv/static/adminfe/static/js/chunk-3365.b73c30a8.js and b/priv/static/adminfe/static/js/chunk-ad1e.eba9db26.js differ diff --git a/priv/static/adminfe/static/js/chunk-3365.b73c30a8.js.map b/priv/static/adminfe/static/js/chunk-ad1e.eba9db26.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-3365.b73c30a8.js.map rename to priv/static/adminfe/static/js/chunk-ad1e.eba9db26.js.map index d2ad4d9aa..d74c2498f 100644 Binary files a/priv/static/adminfe/static/js/chunk-3365.b73c30a8.js.map and b/priv/static/adminfe/static/js/chunk-ad1e.eba9db26.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-commons.a6002038.js b/priv/static/adminfe/static/js/chunk-commons.4ae74caa.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-commons.a6002038.js rename to priv/static/adminfe/static/js/chunk-commons.4ae74caa.js index 2b16da9c7..1ee2ea9e4 100644 Binary files a/priv/static/adminfe/static/js/chunk-commons.a6002038.js and b/priv/static/adminfe/static/js/chunk-commons.4ae74caa.js differ diff --git a/priv/static/adminfe/static/js/chunk-commons.a6002038.js.map b/priv/static/adminfe/static/js/chunk-commons.4ae74caa.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-commons.a6002038.js.map rename to priv/static/adminfe/static/js/chunk-commons.4ae74caa.js.map index 3c7d78861..41a884d15 100644 Binary files a/priv/static/adminfe/static/js/chunk-commons.a6002038.js.map and b/priv/static/adminfe/static/js/chunk-commons.4ae74caa.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-d34d.0f06fe76.js b/priv/static/adminfe/static/js/chunk-e660.feca27c4.js similarity index 97% rename from priv/static/adminfe/static/js/chunk-d34d.0f06fe76.js rename to priv/static/adminfe/static/js/chunk-e660.feca27c4.js index edd221e6e..5659d263e 100644 Binary files a/priv/static/adminfe/static/js/chunk-d34d.0f06fe76.js and b/priv/static/adminfe/static/js/chunk-e660.feca27c4.js differ diff --git a/priv/static/adminfe/static/js/chunk-d34d.0f06fe76.js.map b/priv/static/adminfe/static/js/chunk-e660.feca27c4.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-d34d.0f06fe76.js.map rename to priv/static/adminfe/static/js/chunk-e660.feca27c4.js.map index 6bcd4ed8b..cfc2e08af 100644 Binary files a/priv/static/adminfe/static/js/chunk-d34d.0f06fe76.js.map and b/priv/static/adminfe/static/js/chunk-e660.feca27c4.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-f364.1122502b.js b/priv/static/adminfe/static/js/chunk-f364.1122502b.js new file mode 100644 index 000000000..facad2ed5 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-f364.1122502b.js differ diff --git a/priv/static/adminfe/static/js/chunk-f364.1122502b.js.map b/priv/static/adminfe/static/js/chunk-f364.1122502b.js.map new file mode 100644 index 000000000..f89dabe30 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-f364.1122502b.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-f625.29237434.js b/priv/static/adminfe/static/js/chunk-f625.904137fd.js similarity index 99% rename from priv/static/adminfe/static/js/chunk-f625.29237434.js rename to priv/static/adminfe/static/js/chunk-f625.904137fd.js index 522755a98..053590b28 100644 Binary files a/priv/static/adminfe/static/js/chunk-f625.29237434.js and b/priv/static/adminfe/static/js/chunk-f625.904137fd.js differ diff --git a/priv/static/adminfe/static/js/chunk-f625.29237434.js.map b/priv/static/adminfe/static/js/chunk-f625.904137fd.js.map similarity index 99% rename from priv/static/adminfe/static/js/chunk-f625.29237434.js.map rename to priv/static/adminfe/static/js/chunk-f625.904137fd.js.map index 4f8774c3a..59c1c274e 100644 Binary files a/priv/static/adminfe/static/js/chunk-f625.29237434.js.map and b/priv/static/adminfe/static/js/chunk-f625.904137fd.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.8f631d12.js b/priv/static/adminfe/static/js/runtime.8f631d12.js deleted file mode 100644 index 6fa7d9ee1..000000000 Binary files a/priv/static/adminfe/static/js/runtime.8f631d12.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.8f631d12.js.map b/priv/static/adminfe/static/js/runtime.8f631d12.js.map deleted file mode 100644 index d5c20400e..000000000 Binary files a/priv/static/adminfe/static/js/runtime.8f631d12.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.ba96836e.js b/priv/static/adminfe/static/js/runtime.ba96836e.js new file mode 100644 index 000000000..245c7fe20 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.ba96836e.js differ diff --git a/priv/static/adminfe/static/js/runtime.ba96836e.js.map b/priv/static/adminfe/static/js/runtime.ba96836e.js.map new file mode 100644 index 000000000..f3c5a82af Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.ba96836e.js.map differ diff --git a/priv/static/favicon.png b/priv/static/favicon.png index a96d5d252..098040a00 100644 Binary files a/priv/static/favicon.png and b/priv/static/favicon.png differ diff --git a/priv/static/static/logo.png b/priv/static/images/logo.png similarity index 100% rename from priv/static/static/logo.png rename to priv/static/images/logo.png diff --git a/priv/static/index.html b/priv/static/index.html index f5690a8d6..9b774959a 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/instance/static.css b/priv/static/instance/static.css new file mode 100644 index 000000000..487e1ec27 Binary files /dev/null and b/priv/static/instance/static.css differ diff --git a/priv/static/static/config.json b/priv/static/static/config.json index 0030f78f1..f59e645ac 100644 --- a/priv/static/static/config.json +++ b/priv/static/static/config.json @@ -10,9 +10,10 @@ "hideSitename": false, "hideUserStats": false, "loginMethod": "password", - "logo": "/static/logo.png", + "logo": "/static/logo.svg", "logoMargin": ".1em", "logoMask": true, + "logoLeft": false, "minimalScopesMode": false, "nsfwCensorImage": "", "postContentType": "text/plain", diff --git a/priv/static/static/css/app.77b1644622e3bae24b6b.css.map b/priv/static/static/css/app.77b1644622e3bae24b6b.css.map deleted file mode 100644 index 4b042ef35..000000000 --- a/priv/static/static/css/app.77b1644622e3bae24b6b.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.77b1644622e3bae24b6b.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n font-size: 2em;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 1em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}\n.with-load-more-footer a {\n cursor: pointer;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/app.77b1644622e3bae24b6b.css b/priv/static/static/css/app.9a4c5ede37b2f0230836.css similarity index 98% rename from priv/static/static/css/app.77b1644622e3bae24b6b.css rename to priv/static/static/css/app.9a4c5ede37b2f0230836.css index 8038882c0..22b9fdbe7 100644 Binary files a/priv/static/static/css/app.77b1644622e3bae24b6b.css and b/priv/static/static/css/app.9a4c5ede37b2f0230836.css differ diff --git a/priv/static/static/css/app.9a4c5ede37b2f0230836.css.map b/priv/static/static/css/app.9a4c5ede37b2f0230836.css.map new file mode 100644 index 000000000..f54bd9ee6 --- /dev/null +++ b/priv/static/static/css/app.9a4c5ede37b2f0230836.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.9a4c5ede37b2f0230836.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n margin: 0.2em auto;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 4em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}\n.with-load-more-footer a {\n cursor: pointer;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/font/fontello.1600365488745.eot b/priv/static/static/font/fontello.1600365488745.eot deleted file mode 100644 index 255f50372..000000000 Binary files a/priv/static/static/font/fontello.1600365488745.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1600365488745.svg b/priv/static/static/font/fontello.1600365488745.svg deleted file mode 100644 index 9eddf62ea..000000000 --- a/priv/static/static/font/fontello.1600365488745.svg +++ /dev/null @@ -1,140 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1600365488745.ttf b/priv/static/static/font/fontello.1600365488745.ttf deleted file mode 100644 index 6bda99d50..000000000 Binary files a/priv/static/static/font/fontello.1600365488745.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1600365488745.woff b/priv/static/static/font/fontello.1600365488745.woff deleted file mode 100644 index 11c866ae0..000000000 Binary files a/priv/static/static/font/fontello.1600365488745.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1600365488745.woff2 b/priv/static/static/font/fontello.1600365488745.woff2 deleted file mode 100644 index 06151d28c..000000000 Binary files a/priv/static/static/font/fontello.1600365488745.woff2 and /dev/null differ diff --git a/priv/static/static/fontello.1600365488745.css b/priv/static/static/fontello.1600365488745.css deleted file mode 100644 index 781ff7620..000000000 Binary files a/priv/static/static/fontello.1600365488745.css and /dev/null differ diff --git a/priv/static/static/fontello.json b/priv/static/static/fontello.json deleted file mode 100644 index b0136fd90..000000000 --- a/priv/static/static/fontello.json +++ /dev/null @@ -1,416 +0,0 @@ -{ - "name": "", - "css_prefix_text": "icon-", - "css_use_suffix": false, - "hinting": true, - "units_per_em": 1000, - "ascent": 857, - "glyphs": [ - { - "uid": "9bd60140934a1eb9236fd7a8ab1ff6ba", - "css": "spin4", - "code": 59444, - "src": "fontelico" - }, - { - "uid": "5211af474d3a9848f67f945e2ccaf143", - "css": "cancel", - "code": 59392, - "src": "fontawesome" - }, - { - "uid": "eeec3208c90b7b48e804919d0d2d4a41", - "css": "upload", - "code": 59393, - "src": "fontawesome" - }, - { - "uid": "2a6740fc2f9d0edea54205963f662594", - "css": "spin3", - "code": 59442, - "src": "fontelico" - }, - { - "uid": "c6be5a58ee4e63a5ec399c2b0d15cf2c", - "css": "reply", - "code": 61714, - "src": "fontawesome" - }, - { - "uid": "474656633f79ea2f1dad59ff63f6bf07", - "css": "star", - "code": 59394, - "src": "fontawesome" - }, - { - "uid": "d17030afaecc1e1c22349b99f3c4992a", - "css": "star-empty", - "code": 59395, - "src": "fontawesome" - }, - { - "uid": "09feb4465d9bd1364f4e301c9ddbaa92", - "css": "retweet", - "code": 59396, - "src": "fontawesome" - }, - { - "uid": "7fd683b2c518ceb9e5fa6757f2276faa", - "css": "eye-off", - "code": 59397, - "src": "fontawesome" - }, - { - "uid": "73ffeb70554099177620847206c12457", - "css": "binoculars", - "code": 61925, - "src": "fontawesome" - }, - { - "uid": "e99461abfef3923546da8d745372c995", - "css": "cog", - "code": 59399, - "src": "fontawesome" - }, - { - "uid": "1bafeeb1808a5fe24484c7890096901a", - "css": "user-plus", - "code": 62004, - "src": "fontawesome" - }, - { - "uid": "559647a6f430b3aeadbecd67194451dd", - "css": "menu", - "code": 61641, - "src": "fontawesome" - }, - { - "uid": "0d20938846444af8deb1920dc85a29fb", - "css": "logout", - "code": 59400, - "src": "fontawesome" - }, - { - "uid": "ccddff8e8670dcd130e3cb55fdfc2fd0", - "css": "down-open", - "code": 59401, - "src": "fontawesome" - }, - { - "uid": "44b9e75612c5fad5505edd70d071651f", - "css": "attach", - "code": 59402, - "src": "entypo" - }, - { - "uid": "e15f0d620a7897e2035c18c80142f6d9", - "css": "link-ext", - "code": 61582, - "src": "fontawesome" - }, - { - "uid": "e35de5ea31cd56970498e33efbcb8e36", - "css": "link-ext-alt", - "code": 61583, - "src": "fontawesome" - }, - { - "uid": "381da2c2f7fd51f8de877c044d7f439d", - "css": "picture", - "code": 59403, - "src": "fontawesome" - }, - { - "uid": "872d9516df93eb6b776cc4d94bd97dac", - "css": "video", - "code": 59404, - "src": "fontawesome" - }, - { - "uid": "399ef63b1e23ab1b761dfbb5591fa4da", - "css": "right-open", - "code": 59405, - "src": "fontawesome" - }, - { - "uid": "d870630ff8f81e6de3958ecaeac532f2", - "css": "left-open", - "code": 59406, - "src": "fontawesome" - }, - { - "uid": "fe6697b391355dec12f3d86d6d490397", - "css": "up-open", - "code": 59407, - "src": "fontawesome" - }, - { - "uid": "9c1376672bb4f1ed616fdd78a23667e9", - "css": "comment-empty", - "code": 61669, - "src": "fontawesome" - }, - { - "uid": "ccc2329632396dc096bb638d4b46fb98", - "css": "mail-alt", - "code": 61664, - "src": "fontawesome" - }, - { - "uid": "c1f1975c885aa9f3dad7810c53b82074", - "css": "lock", - "code": 59409, - "src": "fontawesome" - }, - { - "uid": "05376be04a27d5a46e855a233d6e8508", - "css": "lock-open-alt", - "code": 61758, - "src": "fontawesome" - }, - { - "uid": "197375a3cea8cb90b02d06e4ddf1433d", - "css": "globe", - "code": 59410, - "src": "fontawesome" - }, - { - "uid": "b3a9e2dab4d19ea3b2f628242c926bfe", - "css": "brush", - "code": 59411, - "src": "iconic" - }, - { - "uid": "9dd9e835aebe1060ba7190ad2b2ed951", - "css": "search", - "code": 59398, - "src": "fontawesome" - }, - { - "uid": "ca90da02d2c6a3183f2458e4dc416285", - "css": "adjust", - "code": 59414, - "src": "fontawesome" - }, - { - "uid": "5e2ab018e3044337bcef5f7e94098ea1", - "css": "thumbs-up-alt", - "code": 61796, - "src": "fontawesome" - }, - { - "uid": "c76b7947c957c9b78b11741173c8349b", - "css": "attention", - "code": 59412, - "src": "fontawesome" - }, - { - "uid": "1a5cfa186647e8c929c2b17b9fc4dac1", - "css": "plus-squared", - "code": 61694, - "src": "fontawesome" - }, - { - "uid": "44e04715aecbca7f266a17d5a7863c68", - "css": "plus", - "code": 59413, - "src": "fontawesome" - }, - { - "uid": "41087bc74d4b20b55059c60a33bf4008", - "css": "edit", - "code": 59415, - "src": "fontawesome" - }, - { - "uid": "5717236f6134afe2d2a278a5c9b3927a", - "css": "play-circled", - "code": 61764, - "src": "fontawesome" - }, - { - "uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6", - "css": "pencil", - "code": 59416, - "src": "fontawesome" - }, - { - "uid": "266d5d9adf15a61800477a5acf9a4462", - "css": "chart-bar", - "code": 59419, - "src": "fontawesome" - }, - { - "uid": "d862a10e1448589215be19702f98f2c1", - "css": "smile", - "code": 61720, - "src": "fontawesome" - }, - { - "uid": "671f29fa10dda08074a4c6a341bb4f39", - "css": "bell-alt", - "code": 61683, - "src": "fontawesome" - }, - { - "uid": "5bb103cd29de77e0e06a52638527b575", - "css": "wrench", - "code": 59418, - "src": "fontawesome" - }, - { - "uid": "5b0772e9484a1a11646793a82edd622a", - "css": "pin", - "code": 59417, - "src": "fontawesome" - }, - { - "uid": "22411a88489225a018f68db737de3c77", - "css": "ellipsis", - "code": 61761, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M214 411V518Q214 540 199 556T161 571H54Q31 571 16 556T0 518V411Q0 388 16 373T54 357H161Q183 357 199 373T214 411ZM500 411V518Q500 540 484 556T446 571H339Q317 571 301 556T286 518V411Q286 388 301 373T339 357H446Q469 357 484 373T500 411ZM786 411V518Q786 540 770 556T732 571H625Q603 571 587 556T571 518V411Q571 388 587 373T625 357H732Q755 357 770 373T786 411Z", - "width": 785.7 - }, - "search": [ - "ellipsis" - ] - }, - { - "uid": "0bef873af785ead27781fdf98b3ae740", - "css": "bell-ringing-o", - "code": 59408, - "src": "custom_icons", - "selected": true, - "svg": { - "path": "M497.8 0C468.3 0 444.4 23.9 444.4 53.3 444.4 61.1 446.1 68.3 448.9 75 301.7 96.7 213.3 213.3 213.3 320 213.3 588.3 117.8 712.8 35.6 782.2 35.6 821.1 67.8 853.3 106.7 853.3H355.6C355.6 931.7 419.4 995.6 497.8 995.6S640 931.7 640 853.3H888.9C927.8 853.3 960 821.1 960 782.2 877.8 712.8 782.2 588.3 782.2 320 782.2 213.3 693.9 96.7 546.7 75 549.4 68.3 551.1 61.1 551.1 53.3 551.1 23.9 527.2 0 497.8 0ZM189.4 44.8C108.4 118.6 70.5 215.1 71.1 320.2L142.2 319.8C141.7 231.2 170.4 158.3 237.3 97.4L189.4 44.8ZM806.2 44.8L758.3 97.4C825.2 158.3 853.9 231.2 853.3 319.8L924.4 320.2C925.1 215.1 887.2 118.6 806.2 44.8ZM408.9 844.4C413.9 844.4 417.8 848.3 417.8 853.3 417.8 897.2 453.9 933.3 497.8 933.3 502.8 933.3 506.7 937.2 506.7 942.2S502.8 951.1 497.8 951.1C443.9 951.1 400 907.2 400 853.3 400 848.3 403.9 844.4 408.9 844.4Z", - "width": 1000 - }, - "search": [ - "bell-ringing-o" - ] - }, - { - "uid": "0b2b66e526028a6972d51a6f10281b4b", - "css": "zoom-in", - "code": 59420, - "src": "fontawesome" - }, - { - "uid": "0bda4bc779d4c32623dec2e43bd67ee8", - "css": "gauge", - "code": 61668, - "src": "fontawesome" - }, - { - "uid": "31972e4e9d080eaa796290349ae6c1fd", - "css": "users", - "code": 59421, - "src": "fontawesome" - }, - { - "uid": "e82cedfa1d5f15b00c5a81c9bd731ea2", - "css": "info-circled", - "code": 59423, - "src": "fontawesome" - }, - { - "uid": "w3nzesrlbezu6f30q7ytyq919p6gdlb6", - "css": "home-2", - "code": 59425, - "src": "typicons" - }, - { - "uid": "dcedf50ab1ede3283d7a6c70e2fe32f3", - "css": "chat", - "code": 59422, - "src": "fontawesome" - }, - { - "uid": "3a00327e61b997b58518bd43ed83c3df", - "css": "login", - "code": 59424, - "src": "fontawesome" - }, - { - "uid": "f3ebd6751c15a280af5cc5f4a764187d", - "css": "arrow-curved", - "code": 59426, - "src": "iconic" - }, - { - "uid": "0ddd3e8201ccc7d41f7b7c9d27eca6c1", - "css": "link", - "code": 59427, - "src": "fontawesome" - }, - { - "uid": "4aad6bb50b02c18508aae9cbe14e784e", - "css": "share", - "code": 61920, - "src": "fontawesome" - }, - { - "uid": "8b80d36d4ef43889db10bc1f0dc9a862", - "css": "user", - "code": 59428, - "src": "fontawesome" - }, - { - "uid": "12f4ece88e46abd864e40b35e05b11cd", - "css": "ok", - "code": 59431, - "src": "fontawesome" - }, - { - "uid": "4109c474ff99cad28fd5a2c38af2ec6f", - "css": "filter", - "code": 61616, - "src": "fontawesome" - }, - { - "uid": "9a76bc135eac17d2c8b8ad4a5774fc87", - "css": "download", - "code": 59429, - "src": "fontawesome" - }, - { - "uid": "f04a5d24e9e659145b966739c4fde82a", - "css": "bookmark", - "code": 59430, - "src": "fontawesome" - }, - { - "uid": "2f5ef6f6b7aaebc56458ab4e865beff5", - "css": "bookmark-empty", - "code": 61591, - "src": "fontawesome" - }, - { - "uid": "9ea0a737ccc45d6c510dcbae56058849", - "css": "music", - "code": 59432, - "src": "fontawesome" - }, - { - "uid": "1b5a5d7b7e3c71437f5a26befdd045ed", - "css": "doc", - "code": 59433, - "src": "fontawesome" - }, - { - "uid": "98d9c83c1ee7c2c25af784b518c522c5", - "css": "block", - "code": 59434, - "src": "fontawesome" - }, - { - "uid": "3e674995cacc2b09692c096ea7eb6165", - "css": "megaphone", - "code": 59435, - "src": "fontawesome" - } - ] -} \ No newline at end of file diff --git a/priv/static/static/js/10.46fbbdfaf0d4800f349b.js b/priv/static/static/js/10.46f441b948010eda4403.js similarity index 71% rename from priv/static/static/js/10.46fbbdfaf0d4800f349b.js rename to priv/static/static/js/10.46f441b948010eda4403.js index 0fd8463df..308d124c0 100644 Binary files a/priv/static/static/js/10.46fbbdfaf0d4800f349b.js and b/priv/static/static/js/10.46f441b948010eda4403.js differ diff --git a/priv/static/static/js/10.46fbbdfaf0d4800f349b.js.map b/priv/static/static/js/10.46f441b948010eda4403.js.map similarity index 56% rename from priv/static/static/js/10.46fbbdfaf0d4800f349b.js.map rename to priv/static/static/js/10.46f441b948010eda4403.js.map index bee2feb10..e0623e6bf 100644 Binary files a/priv/static/static/js/10.46fbbdfaf0d4800f349b.js.map and b/priv/static/static/js/10.46f441b948010eda4403.js.map differ diff --git a/priv/static/static/js/11.708cc2513c53879a92cc.js b/priv/static/static/js/11.8ff1ed54814f2d34cb3e.js similarity index 99% rename from priv/static/static/js/11.708cc2513c53879a92cc.js rename to priv/static/static/js/11.8ff1ed54814f2d34cb3e.js index 4fe316ecf..cb57f2a65 100644 Binary files a/priv/static/static/js/11.708cc2513c53879a92cc.js and b/priv/static/static/js/11.8ff1ed54814f2d34cb3e.js differ diff --git a/priv/static/static/js/11.708cc2513c53879a92cc.js.map b/priv/static/static/js/11.8ff1ed54814f2d34cb3e.js.map similarity index 56% rename from priv/static/static/js/11.708cc2513c53879a92cc.js.map rename to priv/static/static/js/11.8ff1ed54814f2d34cb3e.js.map index 64c9320c4..4ce6d7227 100644 Binary files a/priv/static/static/js/11.708cc2513c53879a92cc.js.map and b/priv/static/static/js/11.8ff1ed54814f2d34cb3e.js.map differ diff --git a/priv/static/static/js/12.13204bdd0ad5703a3ea3.js b/priv/static/static/js/12.13204bdd0ad5703a3ea3.js new file mode 100644 index 000000000..a89bfeb67 Binary files /dev/null and b/priv/static/static/js/12.13204bdd0ad5703a3ea3.js differ diff --git a/priv/static/static/js/12.b3bf0bc313861d6ec36b.js.map b/priv/static/static/js/12.13204bdd0ad5703a3ea3.js.map similarity index 56% rename from priv/static/static/js/12.b3bf0bc313861d6ec36b.js.map rename to priv/static/static/js/12.13204bdd0ad5703a3ea3.js.map index 28545ac96..366ec2927 100644 Binary files a/priv/static/static/js/12.b3bf0bc313861d6ec36b.js.map and b/priv/static/static/js/12.13204bdd0ad5703a3ea3.js.map differ diff --git a/priv/static/static/js/12.b3bf0bc313861d6ec36b.js b/priv/static/static/js/12.b3bf0bc313861d6ec36b.js deleted file mode 100644 index 4890ca10a..000000000 Binary files a/priv/static/static/js/12.b3bf0bc313861d6ec36b.js and /dev/null differ diff --git a/priv/static/static/js/13.adb8a942514d735722c4.js b/priv/static/static/js/13.e27c3eeddcc4b11c1f54.js similarity index 76% rename from priv/static/static/js/13.adb8a942514d735722c4.js rename to priv/static/static/js/13.e27c3eeddcc4b11c1f54.js index 41abcb5a6..8cd482b41 100644 Binary files a/priv/static/static/js/13.adb8a942514d735722c4.js and b/priv/static/static/js/13.e27c3eeddcc4b11c1f54.js differ diff --git a/priv/static/static/js/13.adb8a942514d735722c4.js.map b/priv/static/static/js/13.e27c3eeddcc4b11c1f54.js.map similarity index 56% rename from priv/static/static/js/13.adb8a942514d735722c4.js.map rename to priv/static/static/js/13.e27c3eeddcc4b11c1f54.js.map index 2b8ff6d6c..0c61c3fca 100644 Binary files a/priv/static/static/js/13.adb8a942514d735722c4.js.map and b/priv/static/static/js/13.e27c3eeddcc4b11c1f54.js.map differ diff --git a/priv/static/static/js/14.273855b3e4e27ce80219.js b/priv/static/static/js/14.273855b3e4e27ce80219.js new file mode 100644 index 000000000..78c0bfebc Binary files /dev/null and b/priv/static/static/js/14.273855b3e4e27ce80219.js differ diff --git a/priv/static/static/js/14.273855b3e4e27ce80219.js.map b/priv/static/static/js/14.273855b3e4e27ce80219.js.map new file mode 100644 index 000000000..9ee527eaa Binary files /dev/null and b/priv/static/static/js/14.273855b3e4e27ce80219.js.map differ diff --git a/priv/static/static/js/14.d015d9b2ea16407e389c.js b/priv/static/static/js/14.d015d9b2ea16407e389c.js deleted file mode 100644 index 200a79625..000000000 Binary files a/priv/static/static/js/14.d015d9b2ea16407e389c.js and /dev/null differ diff --git a/priv/static/static/js/14.d015d9b2ea16407e389c.js.map b/priv/static/static/js/14.d015d9b2ea16407e389c.js.map deleted file mode 100644 index 49dab13f7..000000000 Binary files a/priv/static/static/js/14.d015d9b2ea16407e389c.js.map and /dev/null differ diff --git a/priv/static/static/js/15.19866e6a366ccf982284.js.map b/priv/static/static/js/15.19866e6a366ccf982284.js.map deleted file mode 100644 index 561ab7dcf..000000000 Binary files a/priv/static/static/js/15.19866e6a366ccf982284.js.map and /dev/null differ diff --git a/priv/static/static/js/15.19866e6a366ccf982284.js b/priv/static/static/js/15.afbe29b6665fcd015b2d.js similarity index 98% rename from priv/static/static/js/15.19866e6a366ccf982284.js rename to priv/static/static/js/15.afbe29b6665fcd015b2d.js index 0cc2e266a..b83752240 100644 Binary files a/priv/static/static/js/15.19866e6a366ccf982284.js and b/priv/static/static/js/15.afbe29b6665fcd015b2d.js differ diff --git a/priv/static/static/js/15.afbe29b6665fcd015b2d.js.map b/priv/static/static/js/15.afbe29b6665fcd015b2d.js.map new file mode 100644 index 000000000..c7a0be582 Binary files /dev/null and b/priv/static/static/js/15.afbe29b6665fcd015b2d.js.map differ diff --git a/priv/static/static/js/16.38a984effd54736f6a2c.js.map b/priv/static/static/js/16.38a984effd54736f6a2c.js.map deleted file mode 100644 index 68ee95f97..000000000 Binary files a/priv/static/static/js/16.38a984effd54736f6a2c.js.map and /dev/null differ diff --git a/priv/static/static/js/16.38a984effd54736f6a2c.js b/priv/static/static/js/16.5e3f20da470591d0cabf.js similarity index 99% rename from priv/static/static/js/16.38a984effd54736f6a2c.js rename to priv/static/static/js/16.5e3f20da470591d0cabf.js index b3cebb0bd..e90ed4ca1 100644 Binary files a/priv/static/static/js/16.38a984effd54736f6a2c.js and b/priv/static/static/js/16.5e3f20da470591d0cabf.js differ diff --git a/priv/static/static/js/16.5e3f20da470591d0cabf.js.map b/priv/static/static/js/16.5e3f20da470591d0cabf.js.map new file mode 100644 index 000000000..0c4d0e385 Binary files /dev/null and b/priv/static/static/js/16.5e3f20da470591d0cabf.js.map differ diff --git a/priv/static/static/js/17.9c25507194320db2e85b.js b/priv/static/static/js/17.44e90ef82ee2ef12dc3f.js similarity index 94% rename from priv/static/static/js/17.9c25507194320db2e85b.js rename to priv/static/static/js/17.44e90ef82ee2ef12dc3f.js index 451bf8bd3..9b5adfd12 100644 Binary files a/priv/static/static/js/17.9c25507194320db2e85b.js and b/priv/static/static/js/17.44e90ef82ee2ef12dc3f.js differ diff --git a/priv/static/static/js/17.44e90ef82ee2ef12dc3f.js.map b/priv/static/static/js/17.44e90ef82ee2ef12dc3f.js.map new file mode 100644 index 000000000..1d191b94a Binary files /dev/null and b/priv/static/static/js/17.44e90ef82ee2ef12dc3f.js.map differ diff --git a/priv/static/static/js/17.9c25507194320db2e85b.js.map b/priv/static/static/js/17.9c25507194320db2e85b.js.map deleted file mode 100644 index f843d4400..000000000 Binary files a/priv/static/static/js/17.9c25507194320db2e85b.js.map and /dev/null differ diff --git a/priv/static/static/js/18.94946caca48930c224c7.js.map b/priv/static/static/js/18.94946caca48930c224c7.js.map deleted file mode 100644 index ad04b99ab..000000000 Binary files a/priv/static/static/js/18.94946caca48930c224c7.js.map and /dev/null differ diff --git a/priv/static/static/js/18.94946caca48930c224c7.js b/priv/static/static/js/18.9a5b877f94b2b53065e1.js similarity index 57% rename from priv/static/static/js/18.94946caca48930c224c7.js rename to priv/static/static/js/18.9a5b877f94b2b53065e1.js index 5a1f40c6d..c4aea5b25 100644 Binary files a/priv/static/static/js/18.94946caca48930c224c7.js and b/priv/static/static/js/18.9a5b877f94b2b53065e1.js differ diff --git a/priv/static/static/js/18.9a5b877f94b2b53065e1.js.map b/priv/static/static/js/18.9a5b877f94b2b53065e1.js.map new file mode 100644 index 000000000..61d9a7d41 Binary files /dev/null and b/priv/static/static/js/18.9a5b877f94b2b53065e1.js.map differ diff --git a/priv/static/static/js/19.233c81ac2c28d55e9f13.js b/priv/static/static/js/19.1fd4da643df0abf89122.js similarity index 99% rename from priv/static/static/js/19.233c81ac2c28d55e9f13.js rename to priv/static/static/js/19.1fd4da643df0abf89122.js index ace0a1d41..c1ca1643b 100644 Binary files a/priv/static/static/js/19.233c81ac2c28d55e9f13.js and b/priv/static/static/js/19.1fd4da643df0abf89122.js differ diff --git a/priv/static/static/js/19.1fd4da643df0abf89122.js.map b/priv/static/static/js/19.1fd4da643df0abf89122.js.map new file mode 100644 index 000000000..010c8674d Binary files /dev/null and b/priv/static/static/js/19.1fd4da643df0abf89122.js.map differ diff --git a/priv/static/static/js/19.233c81ac2c28d55e9f13.js.map b/priv/static/static/js/19.233c81ac2c28d55e9f13.js.map deleted file mode 100644 index cd3f7354d..000000000 Binary files a/priv/static/static/js/19.233c81ac2c28d55e9f13.js.map and /dev/null differ diff --git a/priv/static/static/js/2.422e6c756ac673a6fd44.js b/priv/static/static/js/2.422e6c756ac673a6fd44.js new file mode 100644 index 000000000..9fb47e2bf Binary files /dev/null and b/priv/static/static/js/2.422e6c756ac673a6fd44.js differ diff --git a/priv/static/static/js/2.422e6c756ac673a6fd44.js.map b/priv/static/static/js/2.422e6c756ac673a6fd44.js.map new file mode 100644 index 000000000..92fdb4d2c Binary files /dev/null and b/priv/static/static/js/2.422e6c756ac673a6fd44.js.map differ diff --git a/priv/static/static/js/2.e852a6b4b3bba752b838.js b/priv/static/static/js/2.e852a6b4b3bba752b838.js deleted file mode 100644 index 42e446575..000000000 Binary files a/priv/static/static/js/2.e852a6b4b3bba752b838.js and /dev/null differ diff --git a/priv/static/static/js/2.e852a6b4b3bba752b838.js.map b/priv/static/static/js/2.e852a6b4b3bba752b838.js.map deleted file mode 100644 index d698f09e1..000000000 Binary files a/priv/static/static/js/2.e852a6b4b3bba752b838.js.map and /dev/null differ diff --git a/priv/static/static/js/20.818c38d27369c3a4d677.js.map b/priv/static/static/js/20.818c38d27369c3a4d677.js.map deleted file mode 100644 index 696eab20f..000000000 Binary files a/priv/static/static/js/20.818c38d27369c3a4d677.js.map and /dev/null differ diff --git a/priv/static/static/js/20.818c38d27369c3a4d677.js b/priv/static/static/js/20.a64fd29da59076399a27.js similarity index 99% rename from priv/static/static/js/20.818c38d27369c3a4d677.js rename to priv/static/static/js/20.a64fd29da59076399a27.js index 133eac52d..eae5b3947 100644 Binary files a/priv/static/static/js/20.818c38d27369c3a4d677.js and b/priv/static/static/js/20.a64fd29da59076399a27.js differ diff --git a/priv/static/static/js/20.a64fd29da59076399a27.js.map b/priv/static/static/js/20.a64fd29da59076399a27.js.map new file mode 100644 index 000000000..b2917fa10 Binary files /dev/null and b/priv/static/static/js/20.a64fd29da59076399a27.js.map differ diff --git a/priv/static/static/js/21.ce4cda179d888ca6bc2a.js b/priv/static/static/js/21.243d9e6ebf469a2dc740.js similarity index 99% rename from priv/static/static/js/21.ce4cda179d888ca6bc2a.js rename to priv/static/static/js/21.243d9e6ebf469a2dc740.js index 49700403c..61633519b 100644 Binary files a/priv/static/static/js/21.ce4cda179d888ca6bc2a.js and b/priv/static/static/js/21.243d9e6ebf469a2dc740.js differ diff --git a/priv/static/static/js/21.243d9e6ebf469a2dc740.js.map b/priv/static/static/js/21.243d9e6ebf469a2dc740.js.map new file mode 100644 index 000000000..3f98250fa Binary files /dev/null and b/priv/static/static/js/21.243d9e6ebf469a2dc740.js.map differ diff --git a/priv/static/static/js/21.ce4cda179d888ca6bc2a.js.map b/priv/static/static/js/21.ce4cda179d888ca6bc2a.js.map deleted file mode 100644 index 124d58abc..000000000 Binary files a/priv/static/static/js/21.ce4cda179d888ca6bc2a.js.map and /dev/null differ diff --git a/priv/static/static/js/22.2ea93c6cc569ef0256ab.js.map b/priv/static/static/js/22.2ea93c6cc569ef0256ab.js.map deleted file mode 100644 index 773159f01..000000000 Binary files a/priv/static/static/js/22.2ea93c6cc569ef0256ab.js.map and /dev/null differ diff --git a/priv/static/static/js/22.2ea93c6cc569ef0256ab.js b/priv/static/static/js/22.e20ef7e5fefc0964cdd1.js similarity index 99% rename from priv/static/static/js/22.2ea93c6cc569ef0256ab.js rename to priv/static/static/js/22.e20ef7e5fefc0964cdd1.js index 1d2077720..e8f309f8a 100644 Binary files a/priv/static/static/js/22.2ea93c6cc569ef0256ab.js and b/priv/static/static/js/22.e20ef7e5fefc0964cdd1.js differ diff --git a/priv/static/static/js/22.e20ef7e5fefc0964cdd1.js.map b/priv/static/static/js/22.e20ef7e5fefc0964cdd1.js.map new file mode 100644 index 000000000..7780cffe6 Binary files /dev/null and b/priv/static/static/js/22.e20ef7e5fefc0964cdd1.js.map differ diff --git a/priv/static/static/js/23.a57a7845cc20fafd06d1.js b/priv/static/static/js/23.614a35f9ded445292f4a.js similarity index 99% rename from priv/static/static/js/23.a57a7845cc20fafd06d1.js rename to priv/static/static/js/23.614a35f9ded445292f4a.js index b15a888df..a35450986 100644 Binary files a/priv/static/static/js/23.a57a7845cc20fafd06d1.js and b/priv/static/static/js/23.614a35f9ded445292f4a.js differ diff --git a/priv/static/static/js/23.614a35f9ded445292f4a.js.map b/priv/static/static/js/23.614a35f9ded445292f4a.js.map new file mode 100644 index 000000000..4158041f4 Binary files /dev/null and b/priv/static/static/js/23.614a35f9ded445292f4a.js.map differ diff --git a/priv/static/static/js/23.a57a7845cc20fafd06d1.js.map b/priv/static/static/js/23.a57a7845cc20fafd06d1.js.map deleted file mode 100644 index 0e5b421e6..000000000 Binary files a/priv/static/static/js/23.a57a7845cc20fafd06d1.js.map and /dev/null differ diff --git a/priv/static/static/js/24.35eb55a657b5485f8491.js.map b/priv/static/static/js/24.35eb55a657b5485f8491.js.map deleted file mode 100644 index 93ffbb2e9..000000000 Binary files a/priv/static/static/js/24.35eb55a657b5485f8491.js.map and /dev/null differ diff --git a/priv/static/static/js/24.35eb55a657b5485f8491.js b/priv/static/static/js/24.6ae9ca51e51e023afbe4.js similarity index 99% rename from priv/static/static/js/24.35eb55a657b5485f8491.js rename to priv/static/static/js/24.6ae9ca51e51e023afbe4.js index d09d5c371..d075f3b1f 100644 Binary files a/priv/static/static/js/24.35eb55a657b5485f8491.js and b/priv/static/static/js/24.6ae9ca51e51e023afbe4.js differ diff --git a/priv/static/static/js/24.6ae9ca51e51e023afbe4.js.map b/priv/static/static/js/24.6ae9ca51e51e023afbe4.js.map new file mode 100644 index 000000000..7e68d5eaa Binary files /dev/null and b/priv/static/static/js/24.6ae9ca51e51e023afbe4.js.map differ diff --git a/priv/static/static/js/25.5a9efe20e3ae1352e6d2.js b/priv/static/static/js/25.5a9efe20e3ae1352e6d2.js deleted file mode 100644 index e96c5e6ec..000000000 Binary files a/priv/static/static/js/25.5a9efe20e3ae1352e6d2.js and /dev/null differ diff --git a/priv/static/static/js/25.5a9efe20e3ae1352e6d2.js.map b/priv/static/static/js/25.5a9efe20e3ae1352e6d2.js.map deleted file mode 100644 index a506e6fa8..000000000 Binary files a/priv/static/static/js/25.5a9efe20e3ae1352e6d2.js.map and /dev/null differ diff --git a/priv/static/static/js/25.eadae0d48ee5be52a16c.js b/priv/static/static/js/25.eadae0d48ee5be52a16c.js new file mode 100644 index 000000000..a0e44e1aa Binary files /dev/null and b/priv/static/static/js/25.eadae0d48ee5be52a16c.js differ diff --git a/priv/static/static/js/25.eadae0d48ee5be52a16c.js.map b/priv/static/static/js/25.eadae0d48ee5be52a16c.js.map new file mode 100644 index 000000000..aaa5e3a57 Binary files /dev/null and b/priv/static/static/js/25.eadae0d48ee5be52a16c.js.map differ diff --git a/priv/static/static/js/26.cf13231d524e5ca3b3e6.js b/priv/static/static/js/26.8fd0027b982c4bcdc88f.js similarity index 99% rename from priv/static/static/js/26.cf13231d524e5ca3b3e6.js rename to priv/static/static/js/26.8fd0027b982c4bcdc88f.js index adc57d6c7..3b149915b 100644 Binary files a/priv/static/static/js/26.cf13231d524e5ca3b3e6.js and b/priv/static/static/js/26.8fd0027b982c4bcdc88f.js differ diff --git a/priv/static/static/js/26.8fd0027b982c4bcdc88f.js.map b/priv/static/static/js/26.8fd0027b982c4bcdc88f.js.map new file mode 100644 index 000000000..d40f1979a Binary files /dev/null and b/priv/static/static/js/26.8fd0027b982c4bcdc88f.js.map differ diff --git a/priv/static/static/js/26.cf13231d524e5ca3b3e6.js.map b/priv/static/static/js/26.cf13231d524e5ca3b3e6.js.map deleted file mode 100644 index 8654bda10..000000000 Binary files a/priv/static/static/js/26.cf13231d524e5ca3b3e6.js.map and /dev/null differ diff --git a/priv/static/static/js/27.fca8d4f6e444bd14f376.js b/priv/static/static/js/27.6d90a54efba08d261d69.js similarity index 94% rename from priv/static/static/js/27.fca8d4f6e444bd14f376.js rename to priv/static/static/js/27.6d90a54efba08d261d69.js index 9f8b5c85d..e8420a54f 100644 Binary files a/priv/static/static/js/27.fca8d4f6e444bd14f376.js and b/priv/static/static/js/27.6d90a54efba08d261d69.js differ diff --git a/priv/static/static/js/27.6d90a54efba08d261d69.js.map b/priv/static/static/js/27.6d90a54efba08d261d69.js.map new file mode 100644 index 000000000..6685474ce Binary files /dev/null and b/priv/static/static/js/27.6d90a54efba08d261d69.js.map differ diff --git a/priv/static/static/js/27.fca8d4f6e444bd14f376.js.map b/priv/static/static/js/27.fca8d4f6e444bd14f376.js.map deleted file mode 100644 index f6ea8afc9..000000000 Binary files a/priv/static/static/js/27.fca8d4f6e444bd14f376.js.map and /dev/null differ diff --git a/priv/static/static/js/28.e0f9f164e0bfd890dc61.js.map b/priv/static/static/js/28.e0f9f164e0bfd890dc61.js.map deleted file mode 100644 index 536ae2d7a..000000000 Binary files a/priv/static/static/js/28.e0f9f164e0bfd890dc61.js.map and /dev/null differ diff --git a/priv/static/static/js/28.e0f9f164e0bfd890dc61.js b/priv/static/static/js/28.f1353aa382a104262d1a.js similarity index 98% rename from priv/static/static/js/28.e0f9f164e0bfd890dc61.js rename to priv/static/static/js/28.f1353aa382a104262d1a.js index 75ba6d69d..a253284f0 100644 Binary files a/priv/static/static/js/28.e0f9f164e0bfd890dc61.js and b/priv/static/static/js/28.f1353aa382a104262d1a.js differ diff --git a/priv/static/static/js/28.f1353aa382a104262d1a.js.map b/priv/static/static/js/28.f1353aa382a104262d1a.js.map new file mode 100644 index 000000000..3421c9511 Binary files /dev/null and b/priv/static/static/js/28.f1353aa382a104262d1a.js.map differ diff --git a/priv/static/static/js/29.0b69359f0fe5c0785746.js.map b/priv/static/static/js/29.0b69359f0fe5c0785746.js.map deleted file mode 100644 index 65cd6bc82..000000000 Binary files a/priv/static/static/js/29.0b69359f0fe5c0785746.js.map and /dev/null differ diff --git a/priv/static/static/js/29.0b69359f0fe5c0785746.js b/priv/static/static/js/29.39c1e87a689c840395b2.js similarity index 99% rename from priv/static/static/js/29.0b69359f0fe5c0785746.js rename to priv/static/static/js/29.39c1e87a689c840395b2.js index 24d73bcd5..ddb512279 100644 Binary files a/priv/static/static/js/29.0b69359f0fe5c0785746.js and b/priv/static/static/js/29.39c1e87a689c840395b2.js differ diff --git a/priv/static/static/js/29.39c1e87a689c840395b2.js.map b/priv/static/static/js/29.39c1e87a689c840395b2.js.map new file mode 100644 index 000000000..5901ce9b7 Binary files /dev/null and b/priv/static/static/js/29.39c1e87a689c840395b2.js.map differ diff --git a/priv/static/static/js/3.7d21accf4e5bd07e3ebf.js b/priv/static/static/js/3.a0df8a5bcd120d1f8581.js similarity index 99% rename from priv/static/static/js/3.7d21accf4e5bd07e3ebf.js rename to priv/static/static/js/3.a0df8a5bcd120d1f8581.js index d98aadec2..423121114 100644 Binary files a/priv/static/static/js/3.7d21accf4e5bd07e3ebf.js and b/priv/static/static/js/3.a0df8a5bcd120d1f8581.js differ diff --git a/priv/static/static/js/3.7d21accf4e5bd07e3ebf.js.map b/priv/static/static/js/3.a0df8a5bcd120d1f8581.js.map similarity index 99% rename from priv/static/static/js/3.7d21accf4e5bd07e3ebf.js.map rename to priv/static/static/js/3.a0df8a5bcd120d1f8581.js.map index 37826baac..653727d10 100644 Binary files a/priv/static/static/js/3.7d21accf4e5bd07e3ebf.js.map and b/priv/static/static/js/3.a0df8a5bcd120d1f8581.js.map differ diff --git a/priv/static/static/js/30.64736585965c63c2b5d4.js b/priv/static/static/js/30.64736585965c63c2b5d4.js new file mode 100644 index 000000000..4fdbe8c3e Binary files /dev/null and b/priv/static/static/js/30.64736585965c63c2b5d4.js differ diff --git a/priv/static/static/js/30.64736585965c63c2b5d4.js.map b/priv/static/static/js/30.64736585965c63c2b5d4.js.map new file mode 100644 index 000000000..376920946 Binary files /dev/null and b/priv/static/static/js/30.64736585965c63c2b5d4.js.map differ diff --git a/priv/static/static/js/30.fce58be0b52ca3e32fa4.js b/priv/static/static/js/30.fce58be0b52ca3e32fa4.js deleted file mode 100644 index 03a5d65f6..000000000 Binary files a/priv/static/static/js/30.fce58be0b52ca3e32fa4.js and /dev/null differ diff --git a/priv/static/static/js/30.fce58be0b52ca3e32fa4.js.map b/priv/static/static/js/30.fce58be0b52ca3e32fa4.js.map deleted file mode 100644 index f7dc83701..000000000 Binary files a/priv/static/static/js/30.fce58be0b52ca3e32fa4.js.map and /dev/null differ diff --git a/priv/static/static/js/4.5719922a4e807145346d.js b/priv/static/static/js/4.4cde7fdd1fe6bf2a9499.js similarity index 77% rename from priv/static/static/js/4.5719922a4e807145346d.js rename to priv/static/static/js/4.4cde7fdd1fe6bf2a9499.js index 91ea2ac5e..4da4c56fa 100644 Binary files a/priv/static/static/js/4.5719922a4e807145346d.js and b/priv/static/static/js/4.4cde7fdd1fe6bf2a9499.js differ diff --git a/priv/static/static/js/4.5719922a4e807145346d.js.map b/priv/static/static/js/4.4cde7fdd1fe6bf2a9499.js.map similarity index 99% rename from priv/static/static/js/4.5719922a4e807145346d.js.map rename to priv/static/static/js/4.4cde7fdd1fe6bf2a9499.js.map index d5e592cfd..bc040ab9b 100644 Binary files a/priv/static/static/js/4.5719922a4e807145346d.js.map and b/priv/static/static/js/4.4cde7fdd1fe6bf2a9499.js.map differ diff --git a/priv/static/static/js/5.cf05c5ddbdbac890ae35.js b/priv/static/static/js/5.2e165bc072548e533dd4.js similarity index 98% rename from priv/static/static/js/5.cf05c5ddbdbac890ae35.js rename to priv/static/static/js/5.2e165bc072548e533dd4.js index f54d67fb3..cfd84226c 100644 Binary files a/priv/static/static/js/5.cf05c5ddbdbac890ae35.js and b/priv/static/static/js/5.2e165bc072548e533dd4.js differ diff --git a/priv/static/static/js/5.cf05c5ddbdbac890ae35.js.map b/priv/static/static/js/5.2e165bc072548e533dd4.js.map similarity index 57% rename from priv/static/static/js/5.cf05c5ddbdbac890ae35.js.map rename to priv/static/static/js/5.2e165bc072548e533dd4.js.map index 77f2d0898..49959c78e 100644 Binary files a/priv/static/static/js/5.cf05c5ddbdbac890ae35.js.map and b/priv/static/static/js/5.2e165bc072548e533dd4.js.map differ diff --git a/priv/static/static/js/6.260ccd84f8cd2af27970.js b/priv/static/static/js/6.260ccd84f8cd2af27970.js new file mode 100644 index 000000000..fb4a690f4 Binary files /dev/null and b/priv/static/static/js/6.260ccd84f8cd2af27970.js differ diff --git a/priv/static/static/js/6.ecfd3302a692de148391.js.map b/priv/static/static/js/6.260ccd84f8cd2af27970.js.map similarity index 57% rename from priv/static/static/js/6.ecfd3302a692de148391.js.map rename to priv/static/static/js/6.260ccd84f8cd2af27970.js.map index a17c7d297..850fe731a 100644 Binary files a/priv/static/static/js/6.ecfd3302a692de148391.js.map and b/priv/static/static/js/6.260ccd84f8cd2af27970.js.map differ diff --git a/priv/static/static/js/6.ecfd3302a692de148391.js b/priv/static/static/js/6.ecfd3302a692de148391.js deleted file mode 100644 index 354243ec2..000000000 Binary files a/priv/static/static/js/6.ecfd3302a692de148391.js and /dev/null differ diff --git a/priv/static/static/js/7.dd44c3d58fb9dced093d.js b/priv/static/static/js/7.1c41eff6cfc75a00bde4.js similarity index 99% rename from priv/static/static/js/7.dd44c3d58fb9dced093d.js rename to priv/static/static/js/7.1c41eff6cfc75a00bde4.js index cb95efc73..317770a53 100644 Binary files a/priv/static/static/js/7.dd44c3d58fb9dced093d.js and b/priv/static/static/js/7.1c41eff6cfc75a00bde4.js differ diff --git a/priv/static/static/js/7.dd44c3d58fb9dced093d.js.map b/priv/static/static/js/7.1c41eff6cfc75a00bde4.js.map similarity index 57% rename from priv/static/static/js/7.dd44c3d58fb9dced093d.js.map rename to priv/static/static/js/7.1c41eff6cfc75a00bde4.js.map index ae7e35d5d..36f327b3f 100644 Binary files a/priv/static/static/js/7.dd44c3d58fb9dced093d.js.map and b/priv/static/static/js/7.1c41eff6cfc75a00bde4.js.map differ diff --git a/priv/static/static/js/8.636322a87bb10a1754f8.js b/priv/static/static/js/8.9b35c2fee24ab7481e00.js similarity index 99% rename from priv/static/static/js/8.636322a87bb10a1754f8.js rename to priv/static/static/js/8.9b35c2fee24ab7481e00.js index 6e635fb6a..cb7844ffc 100644 Binary files a/priv/static/static/js/8.636322a87bb10a1754f8.js and b/priv/static/static/js/8.9b35c2fee24ab7481e00.js differ diff --git a/priv/static/static/js/8.636322a87bb10a1754f8.js.map b/priv/static/static/js/8.9b35c2fee24ab7481e00.js.map similarity index 57% rename from priv/static/static/js/8.636322a87bb10a1754f8.js.map rename to priv/static/static/js/8.9b35c2fee24ab7481e00.js.map index f074928a5..65f4d5ae9 100644 Binary files a/priv/static/static/js/8.636322a87bb10a1754f8.js.map and b/priv/static/static/js/8.9b35c2fee24ab7481e00.js.map differ diff --git a/priv/static/static/js/9.3a29094f1886648a0af3.js b/priv/static/static/js/9.3a29094f1886648a0af3.js new file mode 100644 index 000000000..eb3516dcd Binary files /dev/null and b/priv/static/static/js/9.3a29094f1886648a0af3.js differ diff --git a/priv/static/static/js/9.3a29094f1886648a0af3.js.map b/priv/static/static/js/9.3a29094f1886648a0af3.js.map new file mode 100644 index 000000000..1b6224a6a Binary files /dev/null and b/priv/static/static/js/9.3a29094f1886648a0af3.js.map differ diff --git a/priv/static/static/js/9.6010dbcce7b4d7c05a18.js b/priv/static/static/js/9.6010dbcce7b4d7c05a18.js deleted file mode 100644 index fcad39a7e..000000000 Binary files a/priv/static/static/js/9.6010dbcce7b4d7c05a18.js and /dev/null differ diff --git a/priv/static/static/js/9.6010dbcce7b4d7c05a18.js.map b/priv/static/static/js/9.6010dbcce7b4d7c05a18.js.map deleted file mode 100644 index e5e1cd823..000000000 Binary files a/priv/static/static/js/9.6010dbcce7b4d7c05a18.js.map and /dev/null differ diff --git a/priv/static/static/js/app.45547c05212c403dd77c.js b/priv/static/static/js/app.45547c05212c403dd77c.js new file mode 100644 index 000000000..219a59493 Binary files /dev/null and b/priv/static/static/js/app.45547c05212c403dd77c.js differ diff --git a/priv/static/static/js/app.45547c05212c403dd77c.js.map b/priv/static/static/js/app.45547c05212c403dd77c.js.map new file mode 100644 index 000000000..e1dd6c992 Binary files /dev/null and b/priv/static/static/js/app.45547c05212c403dd77c.js.map differ diff --git a/priv/static/static/js/app.826c44232e0a76bbd9ba.js b/priv/static/static/js/app.826c44232e0a76bbd9ba.js deleted file mode 100644 index 16762165e..000000000 Binary files a/priv/static/static/js/app.826c44232e0a76bbd9ba.js and /dev/null differ diff --git a/priv/static/static/js/app.826c44232e0a76bbd9ba.js.map b/priv/static/static/js/app.826c44232e0a76bbd9ba.js.map deleted file mode 100644 index b188c3379..000000000 Binary files a/priv/static/static/js/app.826c44232e0a76bbd9ba.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.90c4af83c1ae68f4cd95.js b/priv/static/static/js/vendors~app.90c4af83c1ae68f4cd95.js deleted file mode 100644 index 879a3b312..000000000 Binary files a/priv/static/static/js/vendors~app.90c4af83c1ae68f4cd95.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.90c4af83c1ae68f4cd95.js.map b/priv/static/static/js/vendors~app.90c4af83c1ae68f4cd95.js.map deleted file mode 100644 index 395f83b6b..000000000 Binary files a/priv/static/static/js/vendors~app.90c4af83c1ae68f4cd95.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.952124344a84613dbac0.js b/priv/static/static/js/vendors~app.952124344a84613dbac0.js new file mode 100644 index 000000000..f7943c122 Binary files /dev/null and b/priv/static/static/js/vendors~app.952124344a84613dbac0.js differ diff --git a/priv/static/static/js/vendors~app.952124344a84613dbac0.js.map b/priv/static/static/js/vendors~app.952124344a84613dbac0.js.map new file mode 100644 index 000000000..05fc07c18 Binary files /dev/null and b/priv/static/static/js/vendors~app.952124344a84613dbac0.js.map differ diff --git a/priv/static/static/logo.svg b/priv/static/static/logo.svg new file mode 100644 index 000000000..68e647e6c --- /dev/null +++ b/priv/static/static/logo.svg @@ -0,0 +1,71 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/priv/static/static/terms-of-service.html b/priv/static/static/terms-of-service.html index 3b6bbb36b..2b7bf7697 100644 --- a/priv/static/static/terms-of-service.html +++ b/priv/static/static/terms-of-service.html @@ -6,4 +6,4 @@ Pleroma install containing the real ToS for your instance.

    See the Pleroma documentation for more information.


    - + diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index fa4969025..385ee2f0c 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ diff --git a/priv/static/sw-pleroma.js.map b/priv/static/sw-pleroma.js.map index cd5ea0ae6..0b6a76c2f 100644 Binary files a/priv/static/sw-pleroma.js.map and b/priv/static/sw-pleroma.js.map differ diff --git a/priv/templates/sample_config.eex b/priv/templates/sample_config.eex index cdddc47ea..2f5952ef1 100644 --- a/priv/templates/sample_config.eex +++ b/priv/templates/sample_config.eex @@ -32,8 +32,7 @@ config :pleroma, Pleroma.Repo, username: "<%= dbuser %>", password: "<%= dbpass %>", database: "<%= dbname %>", - hostname: "<%= dbhost %>", - pool_size: 10 + hostname: "<%= dbhost %>" # Configure web push notifications config :web_push_encryption, :vapid_details, diff --git a/test/fixtures/mastodon-delete.json b/test/fixtures/mastodon-delete.json index 87a582002..8559f724e 100644 --- a/test/fixtures/mastodon-delete.json +++ b/test/fixtures/mastodon-delete.json @@ -2,12 +2,9 @@ "type": "Delete", "signature": { "type": "RsaSignature2017", - "signatureValue": "cw0RlfNREf+5VdsOYcCBDrv521eiLsDTAYNHKffjF0bozhCnOh+wHkFik7WamUk$ -uEiN4L2H6vPlGRprAZGRhEwgy+A7rIFQNmLrpW5qV5UNVI/2F7kngEHqZQgbQYj9hW+5GMYmPkHdv3D72ZefGw$ -4Xa2NBLGFpAjQllfzt7kzZLKKY2DM99FdUa64I2Wj3iD04Hs23SbrUdAeuGk/c1Cg6bwGNG4vxoiwn1jikgJLA$ -NAlSGjsRGdR7LfbC7GqWWsW3cSNsLFPoU6FyALjgTrrYoHiXe0QHggw+L3yMLfzB2S/L46/VRbyb+WDKMBIXUL$ -5owmzHSi6e/ZtCI3w==", - "creator": "http://mastodon.example.org/users/gargron#main-key", "created": "2018-03-03T16:24:11Z" + "signatureValue": "cw0RlfNREf+5VdsOYcCBDrv521eiLsDTAYNHKffjF0bozhCnOh+wHkFik7WamUk$uEiN4L2H6vPlGRprAZGRhEwgy+A7rIFQNmLrpW5qV5UNVI/2F7kngEHqZQgbQYj9hW+5GMYmPkHdv3D72ZefGw$4Xa2NBLGFpAjQllfzt7kzZLKKY2DM99FdUa64I2Wj3iD04Hs23SbrUdAeuGk/c1Cg6bwGNG4vxoiwn1jikgJLA$NAlSGjsRGdR7LfbC7GqWWsW3cSNsLFPoU6FyALjgTrrYoHiXe0QHggw+L3yMLfzB2S/L46/VRbyb+WDKMBIXUL$5owmzHSi6e/ZtCI3w==", + "creator": "http://mastodon.example.org/users/gargron#main-key", + "created": "2018-03-03T16:24:11Z" }, "object": { "type": "Tombstone", diff --git a/test/fixtures/mastodon-post-activity-nsfw.json b/test/fixtures/mastodon-post-activity-nsfw.json new file mode 100644 index 000000000..70729a1bd --- /dev/null +++ b/test/fixtures/mastodon-post-activity-nsfw.json @@ -0,0 +1,68 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "Emoji": "toot:Emoji", + "Hashtag": "as:Hashtag", + "atomUri": "ostatus:atomUri", + "conversation": "ostatus:conversation", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "movedTo": "as:movedTo", + "ostatus": "http://ostatus.org#", + "toot": "http://joinmastodon.org/ns#" + } + ], + "actor": "http://mastodon.example.org/users/admin", + "cc": [ + "http://mastodon.example.org/users/admin/followers", + "http://localtesting.pleroma.lol/users/lain" + ], + "id": "http://mastodon.example.org/users/admin/statuses/99512778738411822/activity", + "nickname": "lain", + "object": { + "atomUri": "http://mastodon.example.org/users/admin/statuses/99512778738411822", + "attachment": [], + "attributedTo": "http://mastodon.example.org/users/admin", + "cc": [ + "http://mastodon.example.org/users/admin/followers", + "http://localtesting.pleroma.lol/users/lain" + ], + "content": "

    @lain #moo

    ", + "conversation": "tag:mastodon.example.org,2018-02-12:objectId=20:objectType=Conversation", + "id": "http://mastodon.example.org/users/admin/statuses/99512778738411822", + "inReplyTo": null, + "inReplyToAtomUri": null, + "published": "2018-02-12T14:08:20Z", + "summary": "cw", + "tag": [ + { + "href": "http://localtesting.pleroma.lol/users/lain", + "name": "@lain@localtesting.pleroma.lol", + "type": "Mention" + }, + { + "href": "http://mastodon.example.org/tags/nsfw", + "name": "#NSFW", + "type": "Hashtag" + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note", + "url": "http://mastodon.example.org/@admin/99512778738411822" + }, + "published": "2018-02-12T14:08:20Z", + "signature": { + "created": "2018-02-12T14:08:20Z", + "creator": "http://mastodon.example.org/users/admin#main-key", + "signatureValue": "rnNfcopkc6+Ju73P806popcfwrK9wGYHaJVG1/ZvrlEbWVDzaHjkXqj9Q3/xju5l8CSn9tvSgCCtPFqZsFQwn/pFIFUcw7ZWB2xi4bDm3NZ3S4XQ8JRaaX7og5hFxAhWkGhJhAkfxVnOg2hG+w2d/7d7vRVSC1vo5ip4erUaA/PkWusZvPIpxnRWoXaxJsFmVx0gJgjpJkYDyjaXUlp+jmaoseeZ4EPQUWqHLKJ59PRG0mg8j2xAjYH9nQaN14qMRmTGPxY8gfv/CUFcatA+8VJU9KEsJkDAwLVvglydNTLGrxpAJU78a2eaht0foV43XUIZGe3DKiJPgE+UOKGCJw==", + "type": "RsaSignature2017" + }, + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Create" +} diff --git a/test/fixtures/mastodon/application_actor.json b/test/fixtures/mastodon/application_actor.json new file mode 100644 index 000000000..2089ea049 --- /dev/null +++ b/test/fixtures/mastodon/application_actor.json @@ -0,0 +1,67 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "IdentityProof": "toot:IdentityProof", + "discoverable": "toot:discoverable", + "Device": "toot:Device", + "Ed25519Signature": "toot:Ed25519Signature", + "Ed25519Key": "toot:Ed25519Key", + "Curve25519Key": "toot:Curve25519Key", + "EncryptedMessage": "toot:EncryptedMessage", + "publicKeyBase64": "toot:publicKeyBase64", + "deviceId": "toot:deviceId", + "claim": { + "@type": "@id", + "@id": "toot:claim" + }, + "fingerprintKey": { + "@type": "@id", + "@id": "toot:fingerprintKey" + }, + "identityKey": { + "@type": "@id", + "@id": "toot:identityKey" + }, + "devices": { + "@type": "@id", + "@id": "toot:devices" + }, + "messageFranking": "toot:messageFranking", + "messageType": "toot:messageType", + "cipherText": "toot:cipherText" + } + ], + "id": "https://{{DOMAIN}}/actor", + "type": "Application", + "inbox": "https://{{DOMAIN}}/actor/inbox", + "preferredUsername": "{{DOMAIN}}", + "url": "https://{{DOMAIN}}/about/more?instance_actor=true", + "manuallyApprovesFollowers": true, + "publicKey": { + "id": "https://{{DOMAIN}}/actor#main-key", + "owner": "https://{{DOMAIN}}/actor", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAA0CA08AMIIBCgKCAQEAyi2T2FFZJgRPY+96YQrn\n6J6eF2P60J+nz+/pRc/acv/Nx+NLxxPyXby0F2s60MV7uALRQbBBnf7oNKCd/T4S\nvbr7UXMCWTdaJBpYubMKWT9uBlaUUkUfqL+WTV+IQnlcKtssQ4+AwrAKAZXza8ws\nZypevOsLHzayyEzztmm1KQC9GCUOITCLf7Q6qEhy8z/HuqLBEC0Own0pD7QsbfcS\no1peuZY7g1E/jJ9HR9GqJccMaR0H28KmJ7tT1Yzlyf5uZMRIdPxsoMR9sGLjR2B8\noegSwaf9SogR3ScP395Tt/9Ud1VVzuhpoS8Uy7jKSs+3CuLJsEGoMrib8VyOwadS\n9wIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "endpoints": { + "sharedInbox": "https://{{DOMAIN}}/inbox" + } +} diff --git a/test/fixtures/mewmew_no_name.json b/test/fixtures/mewmew_no_name.json new file mode 100644 index 000000000..532d4cf70 --- /dev/null +++ b/test/fixtures/mewmew_no_name.json @@ -0,0 +1,46 @@ +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + "https://princess.cat/schemas/litepub-0.1.jsonld", + { + "@language" : "und" + } + ], + "attachment" : [], + "capabilities" : { + "acceptsChatMessages" : true + }, + "discoverable" : false, + "endpoints" : { + "oauthAuthorizationEndpoint" : "https://princess.cat/oauth/authorize", + "oauthRegistrationEndpoint" : "https://princess.cat/api/v1/apps", + "oauthTokenEndpoint" : "https://princess.cat/oauth/token", + "sharedInbox" : "https://princess.cat/inbox", + "uploadMedia" : "https://princess.cat/api/ap/upload_media" + }, + "followers" : "https://princess.cat/users/mewmew/followers", + "following" : "https://princess.cat/users/mewmew/following", + "icon" : { + "type" : "Image", + "url" : "https://princess.cat/media/12794fb50e86911e65be97f69196814049dcb398a2f8b58b99bb6591576e648c.png?name=blobcatpresentpink.png" + }, + "id" : "https://princess.cat/users/mewmew", + "image" : { + "type" : "Image", + "url" : "https://princess.cat/media/05d8bf3953ab6028fc920494ffc643fbee9dcef40d7bdd06f107e19acbfbd7f9.png" + }, + "inbox" : "https://princess.cat/users/mewmew/inbox", + "manuallyApprovesFollowers" : true, + "name" : " ", + "outbox" : "https://princess.cat/users/mewmew/outbox", + "preferredUsername" : "mewmew", + "publicKey" : { + "id" : "https://princess.cat/users/mewmew#main-key", + "owner" : "https://princess.cat/users/mewmew", + "publicKeyPem" : "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAru7VpygVef4zrFwnj0Mh\nrbO/2z2EdKN3rERtNrT8zWsLXNLQ50lfpRPnGDrd+xq7Rva4EIu0d5KJJ9n4vtY0\nuxK3On9vA2oyjLlR9O0lI3XTrHJborG3P7IPXrmNUMFpHiFHNqHp5tugUrs1gUFq\n7tmOmM92IP4Wjk8qNHFcsfnUbaPTX7sNIhteQKdi5HrTb/6lrEIe4G/FlMKRqxo3\nRNHuv6SNFQuiUKvFzjzazvjkjvBSm+aFROgdHa2tKl88StpLr7xmuY8qNFCRT6W0\nLacRp6c8ah5f03Kd+xCBVhCKvKaF1K0ERnQTBiitUh85md+Mtx/CoDoLnmpnngR3\nvQIDAQAB\n-----END PUBLIC KEY-----\n\n" + }, + "summary" : "please reply to my posts as direct messages if you have many followers", + "tag" : [], + "type" : "Person", + "url" : "https://princess.cat/users/mewmew" +} diff --git a/test/fixtures/modules/good_mrf.ex b/test/fixtures/modules/good_mrf.ex new file mode 100644 index 000000000..39d0f14ec --- /dev/null +++ b/test/fixtures/modules/good_mrf.ex @@ -0,0 +1,19 @@ +defmodule Fixtures.Modules.GoodMRF do + @behaviour Pleroma.Web.ActivityPub.MRF + + @impl true + def filter(a), do: {:ok, a} + + @impl true + def describe, do: %{} + + @impl true + def config_description do + %{ + key: :good_mrf, + related_policy: "Fixtures.Modules.GoodMRF", + label: "Good MRF", + description: "Some description" + } + end +end diff --git a/test/fixtures/osada-follow-activity.json b/test/fixtures/osada-follow-activity.json index b991eea36..be10ce88f 100644 --- a/test/fixtures/osada-follow-activity.json +++ b/test/fixtures/osada-follow-activity.json @@ -1,56 +1,52 @@ { - "@context":[ + "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", "https://apfed.club/apschema/v1.4" ], - "id":"https://apfed.club/follow/9", - "type":"Follow", - "actor":{ - "type":"Person", - "id":"https://apfed.club/channel/indio", - "preferredUsername":"indio", - "name":"Indio", - "updated":"2019-08-20T23:52:34Z", - "icon":{ - "type":"Image", - "mediaType":"image/jpeg", - "updated":"2019-08-20T23:53:37Z", - "url":"https://apfed.club/photo/profile/l/2", - "height":300, - "width":300 + "id": "https://apfed.club/follow/9", + "type": "Follow", + "actor": { + "type": "Person", + "id": "https://apfed.club/channel/indio", + "preferredUsername": "indio", + "name": "Indio", + "updated": "2019-08-20T23:52:34Z", + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "updated": "2019-08-20T23:53:37Z", + "url": "https://apfed.club/photo/profile/l/2", + "height": 300, + "width": 300 }, - "url":"https://apfed.club/channel/indio", - "inbox":"https://apfed.club/inbox/indio", - "outbox":"https://apfed.club/outbox/indio", - "followers":"https://apfed.club/followers/indio", - "following":"https://apfed.club/following/indio", - "endpoints":{ - "sharedInbox":"https://apfed.club/inbox" + "url": "https://apfed.club/channel/indio", + "inbox": "https://apfed.club/inbox/indio", + "outbox": "https://apfed.club/outbox/indio", + "followers": "https://apfed.club/followers/indio", + "following": "https://apfed.club/following/indio", + "endpoints": { + "sharedInbox": "https://apfed.club/inbox" }, - "publicKey":{ - "id":"https://apfed.club/channel/indio", - "owner":"https://apfed.club/channel/indio", - "publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA77TIR1VuSYFnmDRFGHHb\n4vaGdx9ranzRX4bfOKAqa++Ch5L4EqJpPy08RuM+NrYCYiYl4QQFDSSDXAEgb5g9\nC1TgWTfI7q/E0UBX2Vr0mU6X4i1ztv0tuQvegRjcSJ7l1AvoBs8Ip4MEJ3OPEQhB\ngJqAACB3Gnps4zi2I0yavkxUfGVKr6zKT3BxWh5hTpKC7Do+ChIrVZC2EwxND9K6 -\nsAnQHThcb5EQuvuzUQZKeS7IEOsd0JpZDmJjbfMGrAWE81pLIfEeeA2joCJiBBTO\nglDsW+juvZ+lWqJpMr2hMWpvfrFjJeUawNJCIzsLdVIZR+aKj5yy6yqoS8hkN9Ha\n1MljZpsXl+EmwcwAIqim1YeLwERCEAQ/JWbSt8pQTQbzZ6ibwQ4mchCxacrRbIVR -\nnL59fWMBassJcbY0VwrTugm2SBsYbDjESd55UZV03Rwr8qseGTyi+hH8O7w2SIaY\nzjN6AdZiPmsh00YflzlCk8MSLOHMol1vqIUzXxU8CdXn9+KsuQdZGrTz0YKN/db4\naVwUGJatz2Tsvf7R1tJBjJfeQWOWbbn3pycLVH86LjZ83qngp9ZVnAveUnUqz0yS -\nhe+buZ6UMsfGzbIYon2bKNlz6gYTH0YPcr+cLe+29drtt0GZiXha1agbpo4RB8zE -\naNL2fucF5YT0yNpbd/5WoV0CAwEAAQ==\n-----END PUBLIC KEY-----\n" + "publicKey": { + "id": "https://apfed.club/channel/indio", + "owner": "https://apfed.club/channel/indio", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA77TIR1VuSYFnmDRFGHHb\n4vaGdx9ranzRX4bfOKAqa++Ch5L4EqJpPy08RuM+NrYCYiYl4QQFDSSDXAEgb5g9\nC1TgWTfI7q/E0UBX2Vr0mU6X4i1ztv0tuQvegRjcSJ7l1AvoBs8Ip4MEJ3OPEQhB\ngJqAACB3Gnps4zi2I0yavkxUfGVKr6zKT3BxWh5hTpKC7Do+ChIrVZC2EwxND9K6\nsAnQHThcb5EQuvuzUQZKeS7IEOsd0JpZDmJjbfMGrAWE81pLIfEeeA2joCJiBBTO\nglDsW+juvZ+lWqJpMr2hMWpvfrFjJeUawNJCIzsLdVIZR+aKj5yy6yqoS8hkN9Ha\n1MljZpsXl+EmwcwAIqim1YeLwERCEAQ/JWbSt8pQTQbzZ6ibwQ4mchCxacrRbIVR\nnL59fWMBassJcbY0VwrTugm2SBsYbDjESd55UZV03Rwr8qseGTyi+hH8O7w2SIaY\nzjN6AdZiPmsh00YflzlCk8MSLOHMol1vqIUzXxU8CdXn9+KsuQdZGrTz0YKN/db4\naVwUGJatz2Tsvf7R1tJBjJfeQWOWbbn3pycLVH86LjZ83qngp9ZVnAveUnUqz0yS\nhe+buZ6UMsfGzbIYon2bKNlz6gYTH0YPcr+cLe+29drtt0GZiXha1agbpo4RB8zE\naNL2fucF5YT0yNpbd/5WoV0CAwEAAQ==\n-----END PUBLIC KEY-----\n" } }, - "object":"https://pleroma.site/users/kaniini", - "to":[ + "object": "https://pleroma.site/users/kaniini", + "to": [ "https://pleroma.site/users/kaniini" ], - "signature":{ - "@context":[ + "signature": { + "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], - "type":"RsaSignature2017", - "nonce":"52c035e0a9e81dce8b486159204e97c22637e91f75cdfad5378de91de68e9117", - "creator":"https://apfed.club/channel/indio/public_key_pem", - "created":"2019-08-22T03:38:02Z", - "signatureValue":"oVliRCIqNIh6yUp851dYrF0y21aHp3Rz6VkIpW1pFMWfXuzExyWSfcELpyLseeRmsw5bUu9zJkH44B4G2LiJQKA9UoEQDjrDMZBmbeUpiQqq3DVUzkrBOI8bHZ7xyJ/CjSZcNHHh0MHhSKxswyxWMGi4zIqzkAZG3vRRgoPVHdjPm00sR3B8jBLw1cjoffv+KKeM/zEUpe13gqX9qHAWHHqZepxgSWmq+EKOkRvHUPBXiEJZfXzc5uW+vZ09F3WBYmaRoy8Y0e1P29fnRLqSy7EEINdrHaGclRqoUZyiawpkgy3lWWlynesV/HiLBR7EXT79eKstxf4wfTDaPKBCfTCsOWuMWHr7Genu37ew2/t7eiBGqCwwW12ylhml/OLHgNK3LOhmRABhtfpaFZSxfDVnlXfaLpY1xekVOj2oC0FpBtnoxVKLpIcyLw6dkfSil5ANd+hl59W/bpPA8KT90ii1fSNCo3+FcwQVx0YsPznJNA60XfFuVsme7zNcOst6393e1WriZxBanFpfB63zVQc9u1fjyfktx/yiUNxIlre+sz9OCc0AACn94iRhBYh4bbzdleUOTnM7lnD4Dj2FP+xeDIP8CA8wXUeq5+9kopSp2kAmlUEyFUdg4no7naIeu1SZnopfUg56PsVCp9JHiUK1SYAyWbdC+FbUECu5CvI=" + "type": "RsaSignature2017", + "nonce": "52c035e0a9e81dce8b486159204e97c22637e91f75cdfad5378de91de68e9117", + "creator": "https://apfed.club/channel/indio/public_key_pem", + "created": "2019-08-22T03:38:02Z", + "signatureValue": "oVliRCIqNIh6yUp851dYrF0y21aHp3Rz6VkIpW1pFMWfXuzExyWSfcELpyLseeRmsw5bUu9zJkH44B4G2LiJQKA9UoEQDjrDMZBmbeUpiQqq3DVUzkrBOI8bHZ7xyJ/CjSZcNHHh0MHhSKxswyxWMGi4zIqzkAZG3vRRgoPVHdjPm00sR3B8jBLw1cjoffv+KKeM/zEUpe13gqX9qHAWHHqZepxgSWmq+EKOkRvHUPBXiEJZfXzc5uW+vZ09F3WBYmaRoy8Y0e1P29fnRLqSy7EEINdrHaGclRqoUZyiawpkgy3lWWlynesV/HiLBR7EXT79eKstxf4wfTDaPKBCfTCsOWuMWHr7Genu37ew2/t7eiBGqCwwW12ylhml/OLHgNK3LOhmRABhtfpaFZSxfDVnlXfaLpY1xekVOj2oC0FpBtnoxVKLpIcyLw6dkfSil5ANd+hl59W/bpPA8KT90ii1fSNCo3+FcwQVx0YsPznJNA60XfFuVsme7zNcOst6393e1WriZxBanFpfB63zVQc9u1fjyfktx/yiUNxIlre+sz9OCc0AACn94iRhBYh4bbzdleUOTnM7lnD4Dj2FP+xeDIP8CA8wXUeq5+9kopSp2kAmlUEyFUdg4no7naIeu1SZnopfUg56PsVCp9JHiUK1SYAyWbdC+FbUECu5CvI=" } } diff --git a/test/fixtures/spoofed-object.json b/test/fixtures/spoofed-object.json new file mode 100644 index 000000000..91e34307d --- /dev/null +++ b/test/fixtures/spoofed-object.json @@ -0,0 +1,26 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://patch.cx/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "actor": "https://patch.cx/users/rin", + "attachment": [], + "attributedTo": "https://patch.cx/users/rin", + "cc": [ + "https://patch.cx/users/rin/followers" + ], + "content": "Oracle Corporation (NYSE: ORCL) today announced that it has signed a definitive merger agreement to acquire Pleroma AG (FRA: PLA), for $26.50 per share (approximately $10.3 billion). The transaction has been approved by the boards of directors of both companies and should close by early January.", + "context": "https://patch.cx/contexts/spoof", + "id": "https://patch.cx/objects/spoof", + "published": "2020-10-23T18:02:06.038856Z", + "sensitive": false, + "summary": "Oracle buys Pleroma", + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note" +} diff --git a/test/mix/tasks/pleroma/config_test.exs b/test/mix/tasks/pleroma/config_test.exs index f36648829..0280d208d 100644 --- a/test/mix/tasks/pleroma/config_test.exs +++ b/test/mix/tasks/pleroma/config_test.exs @@ -7,6 +7,7 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do import Pleroma.Factory + alias Mix.Tasks.Pleroma.Config, as: MixTask alias Pleroma.ConfigDB alias Pleroma.Repo @@ -22,30 +23,41 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do :ok end - setup_all do: clear_config(:configurable_from_database, true) + defp config_records do + ConfigDB + |> Repo.all() + |> Enum.sort() + end + + defp insert_config_record(group, key, value) do + insert(:config, + group: group, + key: key, + value: value + ) + end test "error if file with custom settings doesn't exist" do - Mix.Tasks.Pleroma.Config.migrate_to_db("config/not_existance_config_file.exs") + MixTask.migrate_to_db("config/non_existent_config_file.exs") - assert_receive {:mix_shell, :info, - [ - "To migrate settings, you must define custom settings in config/not_existance_config_file.exs." - ]}, - 15 + msg = + "To migrate settings, you must define custom settings in config/non_existent_config_file.exs." + + assert_receive {:mix_shell, :info, [^msg]}, 15 end describe "migrate_to_db/1" do setup do - initial = Application.get_env(:quack, :level) - on_exit(fn -> Application.put_env(:quack, :level, initial) end) + clear_config(:configurable_from_database, true) + clear_config([:quack, :level]) end @tag capture_log: true test "config migration refused when deprecated settings are found" do clear_config([:media_proxy, :whitelist], ["domain_without_scheme.com"]) - assert Repo.all(ConfigDB) == [] + assert config_records() == [] - Mix.Tasks.Pleroma.Config.migrate_to_db("test/fixtures/config/temp.secret.exs") + MixTask.migrate_to_db("test/fixtures/config/temp.secret.exs") assert_received {:mix_shell, :error, [message]} @@ -54,9 +66,9 @@ test "config migration refused when deprecated settings are found" do end test "filtered settings are migrated to db" do - assert Repo.all(ConfigDB) == [] + assert config_records() == [] - Mix.Tasks.Pleroma.Config.migrate_to_db("test/fixtures/config/temp.secret.exs") + MixTask.migrate_to_db("test/fixtures/config/temp.secret.exs") config1 = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"}) config2 = ConfigDB.get_by_params(%{group: ":pleroma", key: ":second_setting"}) @@ -71,18 +83,19 @@ test "filtered settings are migrated to db" do end test "config table is truncated before migration" do - insert(:config, key: :first_setting, value: [key: "value", key2: ["Activity"]]) - assert Repo.aggregate(ConfigDB, :count, :id) == 1 + insert_config_record(:pleroma, :first_setting, key: "value", key2: ["Activity"]) + assert length(config_records()) == 1 - Mix.Tasks.Pleroma.Config.migrate_to_db("test/fixtures/config/temp.secret.exs") + MixTask.migrate_to_db("test/fixtures/config/temp.secret.exs") config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"}) assert config.value == [key: "value", key2: [Repo]] end end - describe "with deletion temp file" do + describe "with deletion of temp file" do setup do + clear_config(:configurable_from_database, true) temp_file = "config/temp.exported_from_db.secret.exs" on_exit(fn -> @@ -93,13 +106,13 @@ test "config table is truncated before migration" do end test "settings are migrated to file and deleted from db", %{temp_file: temp_file} do - insert(:config, key: :setting_first, value: [key: "value", key2: ["Activity"]]) - insert(:config, key: :setting_second, value: [key: "value2", key2: [Repo]]) - insert(:config, group: :quack, key: :level, value: :info) + insert_config_record(:pleroma, :setting_first, key: "value", key2: ["Activity"]) + insert_config_record(:pleroma, :setting_second, key: "value2", key2: [Repo]) + insert_config_record(:quack, :level, :info) - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"]) + MixTask.run(["migrate_from_db", "--env", "temp", "-d"]) - assert Repo.all(ConfigDB) == [] + assert config_records() == [] file = File.read!(temp_file) assert file =~ "config :pleroma, :setting_first," @@ -169,9 +182,9 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil ] ) - Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"]) + MixTask.run(["migrate_from_db", "--env", "temp", "-d"]) - assert Repo.all(ConfigDB) == [] + assert config_records() == [] assert File.exists?(temp_file) {:ok, file} = File.read(temp_file) @@ -186,4 +199,114 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" end end + + describe "operations on database config" do + setup do: clear_config(:configurable_from_database, true) + + test "dumping a specific group" do + insert_config_record(:pleroma, :instance, name: "Pleroma Test") + + insert_config_record(:web_push_encryption, :vapid_details, + subject: "mailto:administrator@example.com", + public_key: + "BOsPL-_KjNnjj_RMvLeR3dTOrcndi4TbMR0cu56gLGfGaT5m1gXxSfRHOcC4Dd78ycQL1gdhtx13qgKHmTM5xAI", + private_key: "Ism6FNdS31nLCA94EfVbJbDdJXCxAZ8cZiB1JQPN_t4" + ) + + MixTask.run(["dump", "pleroma"]) + + assert_receive {:mix_shell, :info, + ["config :pleroma, :instance, [name: \"Pleroma Test\"]\r\n\r\n"]} + + refute_receive { + :mix_shell, + :info, + [ + "config :web_push_encryption, :vapid_details, [subject: \"mailto:administrator@example.com\", public_key: \"BOsPL-_KjNnjj_RMvLeR3dTOrcndi4TbMR0cu56gLGfGaT5m1gXxSfRHOcC4Dd78ycQL1gdhtx13qgKHmTM5xAI\", private_key: \"Ism6FNdS31nLCA94EfVbJbDdJXCxAZ8cZiB1JQPN_t4\"]\r\n\r\n" + ] + } + + # Ensure operations work when using atom syntax + MixTask.run(["dump", ":pleroma"]) + + assert_receive {:mix_shell, :info, + ["config :pleroma, :instance, [name: \"Pleroma Test\"]\r\n\r\n"]} + end + + test "dumping a specific key in a group" do + insert_config_record(:pleroma, :instance, name: "Pleroma Test") + insert_config_record(:pleroma, Pleroma.Captcha, enabled: false) + + MixTask.run(["dump", "pleroma", "Pleroma.Captcha"]) + + refute_receive {:mix_shell, :info, + ["config :pleroma, :instance, [name: \"Pleroma Test\"]\r\n\r\n"]} + + assert_receive {:mix_shell, :info, + ["config :pleroma, Pleroma.Captcha, [enabled: false]\r\n\r\n"]} + end + + test "dumps all configuration successfully" do + insert_config_record(:pleroma, :instance, name: "Pleroma Test") + insert_config_record(:pleroma, Pleroma.Captcha, enabled: false) + + MixTask.run(["dump"]) + + assert_receive {:mix_shell, :info, + ["config :pleroma, :instance, [name: \"Pleroma Test\"]\r\n\r\n"]} + + assert_receive {:mix_shell, :info, + ["config :pleroma, Pleroma.Captcha, [enabled: false]\r\n\r\n"]} + end + end + + describe "when configdb disabled" do + test "refuses to dump" do + clear_config(:configurable_from_database, false) + + insert_config_record(:pleroma, :instance, name: "Pleroma Test") + + MixTask.run(["dump"]) + + msg = + "ConfigDB not enabled. Please check the value of :configurable_from_database in your configuration." + + assert_receive {:mix_shell, :error, [^msg]} + end + end + + describe "destructive operations" do + setup do: clear_config(:configurable_from_database, true) + + setup do + insert_config_record(:pleroma, :instance, name: "Pleroma Test") + insert_config_record(:pleroma, Pleroma.Captcha, enabled: false) + insert_config_record(:pleroma2, :key2, z: 1) + + assert length(config_records()) == 3 + + :ok + end + + test "deletes group of settings" do + MixTask.run(["delete", "--force", "pleroma"]) + + assert [%ConfigDB{group: :pleroma2, key: :key2}] = config_records() + end + + test "deletes specified key" do + MixTask.run(["delete", "--force", "pleroma", "Pleroma.Captcha"]) + + assert [ + %ConfigDB{group: :pleroma, key: :instance}, + %ConfigDB{group: :pleroma2, key: :key2} + ] = config_records() + end + + test "resets entire config" do + MixTask.run(["reset", "--force"]) + + assert config_records() == [] + end + end end diff --git a/test/mix/tasks/pleroma/count_statuses_test.exs b/test/mix/tasks/pleroma/count_statuses_test.exs index c5cd16960..8fe3959ea 100644 --- a/test/mix/tasks/pleroma/count_statuses_test.exs +++ b/test/mix/tasks/pleroma/count_statuses_test.exs @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.CountStatusesTest do + # Uses log capture, has to stay synchronous use Pleroma.DataCase alias Pleroma.User diff --git a/test/mix/tasks/pleroma/database_test.exs b/test/mix/tasks/pleroma/database_test.exs index 292a5ef5f..eefb12426 100644 --- a/test/mix/tasks/pleroma/database_test.exs +++ b/test/mix/tasks/pleroma/database_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.DatabaseTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true use Oban.Testing, repo: Pleroma.Repo alias Pleroma.Activity @@ -73,7 +73,7 @@ test "it prunes old objects from the database" do describe "running update_users_following_followers_counts" do test "following and followers count are updated" do [user, user2] = insert_pair(:user) - {:ok, %User{} = user} = User.follow(user, user2) + {:ok, %User{} = user, _user2} = User.follow(user, user2) following = User.following(user) @@ -87,7 +87,8 @@ test "following and followers count are updated" do assert user.follower_count == 3 - assert :ok == Mix.Tasks.Pleroma.Database.run(["update_users_following_followers_counts"]) + assert {:ok, :ok} == + Mix.Tasks.Pleroma.Database.run(["update_users_following_followers_counts"]) user = User.get_by_id(user.id) diff --git a/test/mix/tasks/pleroma/ecto/migrate_test.exs b/test/mix/tasks/pleroma/ecto/migrate_test.exs index 43df176a1..548357508 100644 --- a/test/mix/tasks/pleroma/ecto/migrate_test.exs +++ b/test/mix/tasks/pleroma/ecto/migrate_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-onl defmodule Mix.Tasks.Pleroma.Ecto.MigrateTest do - use Pleroma.DataCase, async: true + use Pleroma.DataCase import ExUnit.CaptureLog require Logger diff --git a/test/mix/tasks/pleroma/ecto/rollback_test.exs b/test/mix/tasks/pleroma/ecto/rollback_test.exs index 0236e35d5..9e39db8fa 100644 --- a/test/mix/tasks/pleroma/ecto/rollback_test.exs +++ b/test/mix/tasks/pleroma/ecto/rollback_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.Ecto.RollbackTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import ExUnit.CaptureLog require Logger diff --git a/test/mix/tasks/pleroma/instance_test.exs b/test/mix/tasks/pleroma/instance_test.exs index 8a02710ee..1d2dde108 100644 --- a/test/mix/tasks/pleroma/instance_test.exs +++ b/test/mix/tasks/pleroma/instance_test.exs @@ -3,7 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.InstanceTest do - use ExUnit.Case + # Modifies the Application Environment, has to stay synchronous. + use Pleroma.DataCase setup do File.mkdir_p!(tmp_path()) @@ -15,15 +16,17 @@ defmodule Mix.Tasks.Pleroma.InstanceTest do if File.exists?(static_dir) do File.rm_rf(Path.join(static_dir, "robots.txt")) end - - Pleroma.Config.put([:instance, :static_dir], static_dir) end) + # Is being modified by the mix task. + clear_config([:instance, :static_dir]) + :ok end + @uuid Ecto.UUID.generate() defp tmp_path do - "/tmp/generated_files/" + "/tmp/generated_files/#{@uuid}/" end test "running gen" do @@ -88,7 +91,7 @@ test "running gen" do assert generated_config =~ "password: \"dbpass\"" assert generated_config =~ "configurable_from_database: true" assert generated_config =~ "http: [ip: {127, 0, 0, 1}, port: 4000]" - assert generated_config =~ "filters: [Pleroma.Upload.Filter.ExifTool]" + assert generated_config =~ "filters: [Pleroma.Upload.Filter.Exiftool]" assert File.read!(tmp_path() <> "setup.psql") == generated_setup_psql() assert File.exists?(Path.expand("./test/instance/static/robots.txt")) end diff --git a/test/mix/tasks/pleroma/refresh_counter_cache_test.exs b/test/mix/tasks/pleroma/refresh_counter_cache_test.exs index 6a1a9ac17..e79dc0632 100644 --- a/test/mix/tasks/pleroma/refresh_counter_cache_test.exs +++ b/test/mix/tasks/pleroma/refresh_counter_cache_test.exs @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.RefreshCounterCacheTest do + # Uses log capture, has to stay synchronous use Pleroma.DataCase alias Pleroma.Web.CommonAPI import ExUnit.CaptureIO, only: [capture_io: 1] diff --git a/test/mix/tasks/pleroma/relay_test.exs b/test/mix/tasks/pleroma/relay_test.exs index cf48e7dda..b453ed1c6 100644 --- a/test/mix/tasks/pleroma/relay_test.exs +++ b/test/mix/tasks/pleroma/relay_test.exs @@ -100,7 +100,7 @@ test "unfollow when relay is dead" do end) Pleroma.Repo.delete(user) - Cachex.clear(:user_cache) + User.invalidate_cache(user) Mix.Tasks.Pleroma.Relay.run(["unfollow", target_instance]) @@ -137,7 +137,7 @@ test "force unfollow when relay is dead" do end) Pleroma.Repo.delete(user) - Cachex.clear(:user_cache) + User.invalidate_cache(user) Mix.Tasks.Pleroma.Relay.run(["unfollow", target_instance, "--force"]) diff --git a/test/mix/tasks/pleroma/user_test.exs b/test/mix/tasks/pleroma/user_test.exs index ce819f815..de8ab27e5 100644 --- a/test/mix/tasks/pleroma/user_test.exs +++ b/test/mix/tasks/pleroma/user_test.exs @@ -36,7 +36,7 @@ test "user is created" do unsaved = build(:user) # prepare to answer yes - send(self(), {:mix_shell_input, :yes?, true}) + send(self(), {:mix_shell_input, :prompt, "Y"}) Mix.Tasks.Pleroma.User.run([ "new", @@ -55,7 +55,7 @@ test "user is created" do assert_received {:mix_shell, :info, [message]} assert message =~ "user will be created" - assert_received {:mix_shell, :yes?, [message]} + assert_received {:mix_shell, :prompt, [message]} assert message =~ "Continue" assert_received {:mix_shell, :info, [message]} @@ -73,14 +73,14 @@ test "user is not created" do unsaved = build(:user) # prepare to answer no - send(self(), {:mix_shell_input, :yes?, false}) + send(self(), {:mix_shell_input, :prompt, "N"}) Mix.Tasks.Pleroma.User.run(["new", unsaved.nickname, unsaved.email]) assert_received {:mix_shell, :info, [message]} assert message =~ "user will be created" - assert_received {:mix_shell, :yes?, [message]} + assert_received {:mix_shell, :prompt, [message]} assert message =~ "Continue" assert_received {:mix_shell, :info, [message]} @@ -462,24 +462,24 @@ test "it prints an error message when user is not exist" do end end - describe "running toggle_confirmed" do + describe "running confirm" do test "user is confirmed" do %{id: id, nickname: nickname} = insert(:user, confirmation_pending: false) - assert :ok = Mix.Tasks.Pleroma.User.run(["toggle_confirmed", nickname]) + assert :ok = Mix.Tasks.Pleroma.User.run(["confirm", nickname]) assert_received {:mix_shell, :info, [message]} - assert message == "#{nickname} needs confirmation." + assert message == "#{nickname} doesn't need confirmation." user = Repo.get(User, id) - assert user.confirmation_pending - assert user.confirmation_token + refute user.confirmation_pending + refute user.confirmation_token end test "user is not confirmed" do %{id: id, nickname: nickname} = insert(:user, confirmation_pending: true, confirmation_token: "some token") - assert :ok = Mix.Tasks.Pleroma.User.run(["toggle_confirmed", nickname]) + assert :ok = Mix.Tasks.Pleroma.User.run(["confirm", nickname]) assert_received {:mix_shell, :info, [message]} assert message == "#{nickname} doesn't need confirmation." @@ -489,7 +489,7 @@ test "user is not confirmed" do end test "it prints an error message when user is not exist" do - Mix.Tasks.Pleroma.User.run(["toggle_confirmed", "foo"]) + Mix.Tasks.Pleroma.User.run(["confirm", "foo"]) assert_received {:mix_shell, :error, [message]} assert message =~ "No local user" @@ -503,7 +503,7 @@ test "it returns users matching" do moot = insert(:user, nickname: "moot") kawen = insert(:user, nickname: "kawen", name: "fediverse expert moon") - {:ok, user} = User.follow(user, moon) + {:ok, user, moon} = User.follow(user, moon) assert [moon.id, kawen.id] == User.Search.search("moon") |> Enum.map(& &1.id) diff --git a/test/pleroma/activity/ir/topics_test.exs b/test/pleroma/activity/ir/topics_test.exs index 4ddcea1ec..b464822d9 100644 --- a/test/pleroma/activity/ir/topics_test.exs +++ b/test/pleroma/activity/ir/topics_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Activity.Ir.TopicsTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.Activity.Ir.Topics @@ -97,6 +97,20 @@ test "only converts strings to hash tags", %{ refute Enum.member?(topics, "hashtag:2") end + + test "non-local action produces public:remote topic", %{activity: activity} do + activity = %{activity | local: false, actor: "https://lain.com/users/lain"} + topics = Topics.get_activity_topics(activity) + + assert Enum.member?(topics, "public:remote:lain.com") + end + + test "local action doesn't produce public:remote topic", %{activity: activity} do + activity = %{activity | local: true, actor: "https://lain.com/users/lain"} + topics = Topics.get_activity_topics(activity) + + refute Enum.member?(topics, "public:remote:lain.com") + end end describe "public visibility create events with attachments" do @@ -128,6 +142,13 @@ test "non-local doesn't produce public:local:media topics", %{activity: activity refute Enum.member?(topics, "public:local:media") end + + test "non-local action produces public:remote:media topic", %{activity: activity} do + activity = %{activity | local: false, actor: "https://lain.com/users/lain"} + topics = Topics.get_activity_topics(activity) + + assert Enum.member?(topics, "public:remote:media:lain.com") + end end describe "non-public visibility" do diff --git a/test/pleroma/activity/search_test.exs b/test/pleroma/activity/search_test.exs new file mode 100644 index 000000000..49b7aa292 --- /dev/null +++ b/test/pleroma/activity/search_test.exs @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Activity.SearchTest do + alias Pleroma.Activity.Search + alias Pleroma.Web.CommonAPI + import Pleroma.Factory + + use Pleroma.DataCase, async: true + + test "it finds something" do + user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"}) + + [result] = Search.search(nil, "wednesday") + + assert result.id == post.id + end + + test "using plainto_tsquery on postgres < 11" do + old_version = :persistent_term.get({Pleroma.Repo, :postgres_version}) + :persistent_term.put({Pleroma.Repo, :postgres_version}, 10.0) + on_exit(fn -> :persistent_term.put({Pleroma.Repo, :postgres_version}, old_version) end) + + user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"}) + {:ok, _post2} = CommonAPI.post(user, %{status: "it's wednesday my bros"}) + + # plainto doesn't understand complex queries + assert [result] = Search.search(nil, "wednesday -dudes") + + assert result.id == post.id + end + + test "using websearch_to_tsquery" do + user = insert(:user) + {:ok, _post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"}) + {:ok, other_post} = CommonAPI.post(user, %{status: "it's wednesday my bros"}) + + assert [result] = Search.search(nil, "wednesday -dudes") + + assert result.id == other_post.id + end +end diff --git a/test/pleroma/activity_test.exs b/test/pleroma/activity_test.exs index ee6a99cc3..105f9f766 100644 --- a/test/pleroma/activity_test.exs +++ b/test/pleroma/activity_test.exs @@ -197,6 +197,13 @@ test "all_by_ids_with_object/1" do assert [%{id: ^id1, object: %Object{}}, %{id: ^id2, object: %Object{}}] = activities end + test "get_by_id_with_user_actor/1" do + user = insert(:user) + activity = insert(:note_activity, note: insert(:note, user: user)) + + assert Activity.get_by_id_with_user_actor(activity.id).user_actor == user + end + test "get_by_id_with_object/1" do %{id: id} = insert(:note_activity) @@ -231,4 +238,20 @@ test "all_by_actor_and_id/2" do assert [%Activity{id: ^id1}, %Activity{id: ^id2}] = activities end + + test "get_by_object_ap_id_with_object/1" do + user = insert(:user) + another = insert(:user) + + {:ok, %{id: id, object: %{data: %{"id" => obj_id}}}} = + Pleroma.Web.CommonAPI.post(user, %{status: "cofe"}) + + Pleroma.Web.CommonAPI.favorite(another, id) + + assert obj_id + |> Pleroma.Activity.Queries.by_object_id() + |> Repo.aggregate(:count, :id) == 2 + + assert %{id: ^id} = Activity.get_by_object_ap_id_with_object(obj_id) + end end diff --git a/test/pleroma/application_requirements_test.exs b/test/pleroma/application_requirements_test.exs index c505ae229..e3cca5487 100644 --- a/test/pleroma/application_requirements_test.exs +++ b/test/pleroma/application_requirements_test.exs @@ -12,6 +12,26 @@ defmodule Pleroma.ApplicationRequirementsTest do alias Pleroma.Config alias Pleroma.Repo + describe "check_repo_pool_size!/1" do + test "raises if the pool size is unexpected" do + clear_config([Pleroma.Repo, :pool_size], 11) + clear_config([:dangerzone, :override_repo_pool_size], false) + + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "Repo.pool_size different than recommended value.", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + + test "doesn't raise if the pool size is unexpected but the respective flag is set" do + clear_config([Pleroma.Repo, :pool_size], 11) + clear_config([:dangerzone, :override_repo_pool_size], true) + + assert Pleroma.ApplicationRequirements.verify!() == :ok + end + end + describe "check_welcome_message_config!/1" do setup do: clear_config([:welcome]) setup do: clear_config([Pleroma.Emails.Mailer]) diff --git a/test/pleroma/bbs/handler_test.exs b/test/pleroma/bbs/handler_test.exs index eb716486e..bba8fab0f 100644 --- a/test/pleroma/bbs/handler_test.exs +++ b/test/pleroma/bbs/handler_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.BBS.HandlerTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.BBS.Handler alias Pleroma.Object @@ -19,7 +19,7 @@ test "getting the home timeline" do user = insert(:user) followed = insert(:user) - {:ok, user} = User.follow(user, followed) + {:ok, user, followed} = User.follow(user, followed) {:ok, _first} = CommonAPI.post(user, %{status: "hey"}) {:ok, _second} = CommonAPI.post(followed, %{status: "hello"}) diff --git a/test/pleroma/bookmark_test.exs b/test/pleroma/bookmark_test.exs index 2726fe7cd..ef090d785 100644 --- a/test/pleroma/bookmark_test.exs +++ b/test/pleroma/bookmark_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.BookmarkTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Bookmark alias Pleroma.Web.CommonAPI diff --git a/test/pleroma/captcha_test.exs b/test/pleroma/captcha_test.exs index 1b9f4a12f..bde3c72f7 100644 --- a/test/pleroma/captcha_test.exs +++ b/test/pleroma/captcha_test.exs @@ -80,7 +80,6 @@ test "validate" do assert is_binary(answer) assert :ok = Captcha.validate(token, "63615261b77f5354fb8c4e4986477555", answer) - Cachex.del(:used_captcha_cache, token) end test "doesn't validate invalid answer" do diff --git a/test/pleroma/chat_test.exs b/test/pleroma/chat_test.exs index 9e8a9ebf0..1dd04916c 100644 --- a/test/pleroma/chat_test.exs +++ b/test/pleroma/chat_test.exs @@ -73,7 +73,8 @@ test "a returning chat will have an updated `update_at` field" do other_user = insert(:user) {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - :timer.sleep(1500) + {:ok, chat} = time_travel(chat, -2) + {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) assert chat.id == chat_two.id diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 0cfed4555..f52629f8a 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -12,7 +12,7 @@ defmodule Pleroma.Config.DeprecationWarningsTest do alias Pleroma.Config.DeprecationWarnings test "check_old_mrf_config/0" do - clear_config([:instance, :rewrite_policy], Pleroma.Web.ActivityPub.MRF.NoOpPolicy) + clear_config([:instance, :rewrite_policy], []) clear_config([:instance, :mrf_transparency], true) clear_config([:instance, :mrf_transparency_exclusions], []) diff --git a/test/pleroma/config_test.exs b/test/pleroma/config_test.exs index 1556e4237..f524d90dd 100644 --- a/test/pleroma/config_test.exs +++ b/test/pleroma/config_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ConfigTest do - use ExUnit.Case + use Pleroma.DataCase test "get/1 with an atom" do assert Pleroma.Config.get(:instance) == Application.get_env(:pleroma, :instance) diff --git a/test/pleroma/conversation/participation_test.exs b/test/pleroma/conversation/participation_test.exs index 59a1b6492..122b10486 100644 --- a/test/pleroma/conversation/participation_test.exs +++ b/test/pleroma/conversation/participation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Conversation.ParticipationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Conversation alias Pleroma.Conversation.Participation @@ -37,9 +37,8 @@ test "for a new conversation or a reply, it doesn't mark the author's participat [%{read: true}] = Participation.for_user(user) [%{read: false} = participation] = Participation.for_user(other_user) - - assert User.get_cached_by_id(user.id).unread_conversation_count == 0 - assert User.get_cached_by_id(other_user.id).unread_conversation_count == 1 + assert Participation.unread_count(user) == 0 + assert Participation.unread_count(other_user) == 1 {:ok, _} = CommonAPI.post(other_user, %{ @@ -54,8 +53,8 @@ test "for a new conversation or a reply, it doesn't mark the author's participat [%{read: false}] = Participation.for_user(user) [%{read: true}] = Participation.for_user(other_user) - assert User.get_cached_by_id(user.id).unread_conversation_count == 1 - assert User.get_cached_by_id(other_user.id).unread_conversation_count == 0 + assert Participation.unread_count(user) == 1 + assert Participation.unread_count(other_user) == 0 end test "for a new conversation, it sets the recipents of the participation" do @@ -97,12 +96,11 @@ test "it creates a participation for a conversation and a user" do {:ok, %Participation{} = participation} = Participation.create_for_user_and_conversation(user, conversation) + {:ok, participation} = time_travel(participation, -2) + assert participation.user_id == user.id assert participation.conversation_id == conversation.id - # Needed because updated_at is accurate down to a second - :timer.sleep(1000) - # Creating again returns the same participation {:ok, %Participation{} = participation_two} = Participation.create_for_user_and_conversation(user, conversation) @@ -264,7 +262,7 @@ test "when the user blocks a recipient, the existing conversations with them are assert [%{read: false}, %{read: false}, %{read: false}, %{read: false}] = Participation.for_user(blocker) - assert User.get_cached_by_id(blocker.id).unread_conversation_count == 4 + assert Participation.unread_count(blocker) == 4 {:ok, _user_relationship} = User.block(blocker, blocked) @@ -272,15 +270,15 @@ test "when the user blocks a recipient, the existing conversations with them are assert [%{read: true}, %{read: true}, %{read: true}, %{read: false}] = Participation.for_user(blocker) - assert User.get_cached_by_id(blocker.id).unread_conversation_count == 1 + assert Participation.unread_count(blocker) == 1 # The conversation is not marked as read for the blocked user assert [_, _, %{read: false}] = Participation.for_user(blocked) - assert User.get_cached_by_id(blocked.id).unread_conversation_count == 1 + assert Participation.unread_count(blocker) == 1 # The conversation is not marked as read for the third user assert [%{read: false}, _, _] = Participation.for_user(third_user) - assert User.get_cached_by_id(third_user.id).unread_conversation_count == 1 + assert Participation.unread_count(third_user) == 1 end test "the new conversation with the blocked user is not marked as unread " do @@ -298,7 +296,7 @@ test "the new conversation with the blocked user is not marked as unread " do }) assert [%{read: true}] = Participation.for_user(blocker) - assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0 + assert Participation.unread_count(blocker) == 0 # When the blocked user is a recipient {:ok, _direct2} = @@ -308,10 +306,10 @@ test "the new conversation with the blocked user is not marked as unread " do }) assert [%{read: true}, %{read: true}] = Participation.for_user(blocker) - assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0 + assert Participation.unread_count(blocker) == 0 assert [%{read: false}, _] = Participation.for_user(blocked) - assert User.get_cached_by_id(blocked.id).unread_conversation_count == 1 + assert Participation.unread_count(blocked) == 1 end test "the conversation with the blocked user is not marked as unread on a reply" do @@ -327,8 +325,8 @@ test "the conversation with the blocked user is not marked as unread on a reply" {:ok, _user_relationship} = User.block(blocker, blocked) assert [%{read: true}] = Participation.for_user(blocker) - assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0 + assert Participation.unread_count(blocker) == 0 assert [blocked_participation] = Participation.for_user(blocked) # When it's a reply from the blocked user @@ -340,8 +338,8 @@ test "the conversation with the blocked user is not marked as unread on a reply" }) assert [%{read: true}] = Participation.for_user(blocker) - assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0 + assert Participation.unread_count(blocker) == 0 assert [third_user_participation] = Participation.for_user(third_user) # When it's a reply from the third user @@ -353,11 +351,12 @@ test "the conversation with the blocked user is not marked as unread on a reply" }) assert [%{read: true}] = Participation.for_user(blocker) - assert User.get_cached_by_id(blocker.id).unread_conversation_count == 0 + assert Participation.unread_count(blocker) == 0 # Marked as unread for the blocked user assert [%{read: false}] = Participation.for_user(blocked) - assert User.get_cached_by_id(blocked.id).unread_conversation_count == 1 + + assert Participation.unread_count(blocked) == 1 end end end diff --git a/test/pleroma/earmark_renderer_test.exs b/test/pleroma/earmark_renderer_test.exs index 220d97d16..73aaec7f4 100644 --- a/test/pleroma/earmark_renderer_test.exs +++ b/test/pleroma/earmark_renderer_test.exs @@ -2,7 +2,7 @@ # Copyright ยฉ 2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.EarmarkRendererTest do - use ExUnit.Case + use Pleroma.DataCase, async: true test "Paragraph" do code = ~s[Hello\n\nWorld!] diff --git a/test/pleroma/ecto_type/activity_pub/object_validators/date_time_test.exs b/test/pleroma/ecto_type/activity_pub/object_validators/date_time_test.exs index 812463454..a8471e2e3 100644 --- a/test/pleroma/ecto_type/activity_pub/object_validators/date_time_test.exs +++ b/test/pleroma/ecto_type/activity_pub/object_validators/date_time_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.DateTimeTest do alias Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime - use Pleroma.DataCase + use Pleroma.DataCase, async: true test "it validates an xsd:Datetime" do valid_strings = [ diff --git a/test/pleroma/ecto_type/activity_pub/object_validators/object_id_test.exs b/test/pleroma/ecto_type/activity_pub/object_validators/object_id_test.exs index 732e2365f..3b6006854 100644 --- a/test/pleroma/ecto_type/activity_pub/object_validators/object_id_test.exs +++ b/test/pleroma/ecto_type/activity_pub/object_validators/object_id_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectIDTest do alias Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID - use Pleroma.DataCase + use Pleroma.DataCase, async: true @uris [ "http://lain.com/users/lain", diff --git a/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs b/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs index 2e6a0c83d..b7eb59ab0 100644 --- a/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs +++ b/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.RecipientsTest do alias Pleroma.EctoType.ActivityPub.ObjectValidators.Recipients - use Pleroma.DataCase + use Pleroma.DataCase, async: true test "it asserts that all elements of the list are object ids" do list = ["https://lain.com/users/lain", "invalid"] diff --git a/test/pleroma/ecto_type/activity_pub/object_validators/safe_text_test.exs b/test/pleroma/ecto_type/activity_pub/object_validators/safe_text_test.exs index 7eddd2388..154363f68 100644 --- a/test/pleroma/ecto_type/activity_pub/object_validators/safe_text_test.exs +++ b/test/pleroma/ecto_type/activity_pub/object_validators/safe_text_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.SafeTextTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.EctoType.ActivityPub.ObjectValidators.SafeText diff --git a/test/pleroma/emails/admin_email_test.exs b/test/pleroma/emails/admin_email_test.exs index 155057f3e..9aaf7b04f 100644 --- a/test/pleroma/emails/admin_email_test.exs +++ b/test/pleroma/emails/admin_email_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emails.AdminEmailTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Emails.AdminEmail @@ -19,8 +19,8 @@ test "build report email" do AdminEmail.report(to_user, reporter, account, [%{name: "Test", id: "12"}], "Test comment") status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, "12") - reporter_url = Helpers.user_feed_url(Pleroma.Web.Endpoint, :feed_redirect, reporter.id) - account_url = Helpers.user_feed_url(Pleroma.Web.Endpoint, :feed_redirect, account.id) + reporter_url = reporter.ap_id + account_url = account.ap_id assert res.to == [{to_user.name, to_user.email}] assert res.from == {config[:name], config[:notify_email]} @@ -54,7 +54,7 @@ test "new unapproved registration email" do res = AdminEmail.new_unapproved_registration(to_user, account) - account_url = Helpers.user_feed_url(Pleroma.Web.Endpoint, :feed_redirect, account.id) + account_url = account.ap_id assert res.to == [{to_user.name, to_user.email}] assert res.from == {config[:name], config[:notify_email]} diff --git a/test/pleroma/emails/user_email_test.exs b/test/pleroma/emails/user_email_test.exs index a75623bb4..bd21d8dec 100644 --- a/test/pleroma/emails/user_email_test.exs +++ b/test/pleroma/emails/user_email_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emails.UserEmailTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Emails.UserEmail alias Pleroma.Web.Endpoint @@ -45,4 +45,15 @@ test "build account confirmation email" do assert email.html_body =~ Router.Helpers.confirm_email_url(Endpoint, :confirm_email, user.id, "conf-token") end + + test "build approval pending email" do + config = Pleroma.Config.get(:instance) + user = insert(:user) + email = UserEmail.approval_pending_email(user) + + assert email.from == {config[:name], config[:notify_email]} + assert email.to == [{user.name, user.email}] + assert email.subject == "Your account is awaiting approval" + assert email.html_body =~ "Awaiting Approval" + end end diff --git a/test/pleroma/emoji/formatter_test.exs b/test/pleroma/emoji/formatter_test.exs index 12af6cd8b..096d23ca6 100644 --- a/test/pleroma/emoji/formatter_test.exs +++ b/test/pleroma/emoji/formatter_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Emoji.FormatterTest do alias Pleroma.Emoji.Formatter - use Pleroma.DataCase + use Pleroma.DataCase, async: true describe "emojify" do test "it adds cool emoji" do diff --git a/test/pleroma/emoji/pack_test.exs b/test/pleroma/emoji/pack_test.exs index 70d1eaa1b..158dfee06 100644 --- a/test/pleroma/emoji/pack_test.exs +++ b/test/pleroma/emoji/pack_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emoji.PackTest do - use ExUnit.Case, async: true + use Pleroma.DataCase alias Pleroma.Emoji.Pack @emoji_path Path.join( diff --git a/test/pleroma/emoji_test.exs b/test/pleroma/emoji_test.exs index 1dd3c58c6..c99c9ef4c 100644 --- a/test/pleroma/emoji_test.exs +++ b/test/pleroma/emoji_test.exs @@ -3,14 +3,28 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.EmojiTest do - use ExUnit.Case + use ExUnit.Case, async: true alias Pleroma.Emoji describe "is_unicode_emoji?/1" do test "tells if a string is an unicode emoji" do refute Emoji.is_unicode_emoji?("X") - assert Emoji.is_unicode_emoji?("โ˜‚") + refute Emoji.is_unicode_emoji?("ใญ") + + # Only accept fully-qualified (RGI) emoji + # See http://www.unicode.org/reports/tr51/ + refute Emoji.is_unicode_emoji?("โค") + refute Emoji.is_unicode_emoji?("โ˜‚") + assert Emoji.is_unicode_emoji?("๐Ÿฅบ") + assert Emoji.is_unicode_emoji?("๐Ÿคฐ") + assert Emoji.is_unicode_emoji?("โค๏ธ") + assert Emoji.is_unicode_emoji?("๐Ÿณ๏ธโ€โšง๏ธ") + + # Additionally, we accept regional indicators. + assert Emoji.is_unicode_emoji?("๐Ÿ‡ต") + assert Emoji.is_unicode_emoji?("๐Ÿ‡ด") + assert Emoji.is_unicode_emoji?("๐Ÿ‡ฌ") end end diff --git a/test/pleroma/filter_test.exs b/test/pleroma/filter_test.exs index 0a5c4426a..da9515902 100644 --- a/test/pleroma/filter_test.exs +++ b/test/pleroma/filter_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.FilterTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory diff --git a/test/pleroma/following_relationship_test.exs b/test/pleroma/following_relationship_test.exs index 17a468abb..f0d2c3846 100644 --- a/test/pleroma/following_relationship_test.exs +++ b/test/pleroma/following_relationship_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.FollowingRelationshipTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.FollowingRelationship alias Pleroma.Web.ActivityPub.InternalFetchActor diff --git a/test/pleroma/formatter_test.exs b/test/pleroma/formatter_test.exs index f066bd50a..5781a3f01 100644 --- a/test/pleroma/formatter_test.exs +++ b/test/pleroma/formatter_test.exs @@ -241,16 +241,14 @@ test "it can parse mentions and return the relevant users" do "@@gsimg According to @archaeme, that is @daggsy. Also hello @archaeme@archae.me and @o and @@@jimm" o = insert(:user, %{nickname: "o"}) - jimm = insert(:user, %{nickname: "jimm"}) - gsimg = insert(:user, %{nickname: "gsimg"}) + _jimm = insert(:user, %{nickname: "jimm"}) + _gsimg = insert(:user, %{nickname: "gsimg"}) archaeme = insert(:user, %{nickname: "archaeme"}) archaeme_remote = insert(:user, %{nickname: "archaeme@archae.me"}) expected_mentions = [ {"@archaeme", archaeme}, {"@archaeme@archae.me", archaeme_remote}, - {"@gsimg", gsimg}, - {"@jimm", jimm}, {"@o", o} ] diff --git a/test/pleroma/frontend_test.exs b/test/pleroma/frontend_test.exs new file mode 100644 index 000000000..223625857 --- /dev/null +++ b/test/pleroma/frontend_test.exs @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.FrontendTest do + use Pleroma.DataCase + alias Pleroma.Frontend + + @dir "test/frontend_static_test" + + setup do + File.mkdir_p!(@dir) + clear_config([:instance, :static_dir], @dir) + + on_exit(fn -> + File.rm_rf(@dir) + end) + end + + test "it downloads and unzips a known frontend" do + clear_config([:frontends, :available], %{ + "pleroma" => %{ + "ref" => "fantasy", + "name" => "pleroma", + "build_url" => "http://gensokyo.2hu/builds/${ref}" + } + }) + + Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/builds/fantasy"} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend_dist.zip")} + end) + + Frontend.install("pleroma") + + assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"])) + end + + test "it also works given a file" do + clear_config([:frontends, :available], %{ + "pleroma" => %{ + "ref" => "fantasy", + "name" => "pleroma", + "build_dir" => "" + } + }) + + folder = Path.join([@dir, "frontends", "pleroma", "fantasy"]) + previously_existing = Path.join([folder, "temp"]) + File.mkdir_p!(folder) + File.write!(previously_existing, "yey") + assert File.exists?(previously_existing) + + Frontend.install("pleroma", file: "test/fixtures/tesla_mock/frontend.zip") + + assert File.exists?(Path.join([folder, "test.txt"])) + refute File.exists?(previously_existing) + end + + test "it downloads and unzips unknown frontends" do + Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")} + end) + + Frontend.install("unknown", + ref: "baka", + build_url: "http://gensokyo.2hu/madeup.zip", + build_dir: "" + ) + + assert File.exists?(Path.join([@dir, "frontends", "unknown", "baka", "test.txt"])) + end +end diff --git a/test/pleroma/healthcheck_test.exs b/test/pleroma/healthcheck_test.exs index e341e6983..a1bc25d25 100644 --- a/test/pleroma/healthcheck_test.exs +++ b/test/pleroma/healthcheck_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HealthcheckTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Healthcheck test "system_info/0" do diff --git a/test/pleroma/html_test.exs b/test/pleroma/html_test.exs index 7d3756884..9737f2458 100644 --- a/test/pleroma/html_test.exs +++ b/test/pleroma/html_test.exs @@ -6,7 +6,7 @@ defmodule Pleroma.HTMLTest do alias Pleroma.HTML alias Pleroma.Object alias Pleroma.Web.CommonAPI - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory diff --git a/test/pleroma/http/adapter_helper/gun_test.exs b/test/pleroma/http/adapter_helper/gun_test.exs index 80589c73d..487d2e7c1 100644 --- a/test/pleroma/http/adapter_helper/gun_test.exs +++ b/test/pleroma/http/adapter_helper/gun_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.AdapterHelper.GunTest do - use ExUnit.Case, async: true + use ExUnit.Case use Pleroma.Tests.Helpers import Mox diff --git a/test/pleroma/instances/instance_test.exs b/test/pleroma/instances/instance_test.exs index 4f0805100..2c6389e4f 100644 --- a/test/pleroma/instances/instance_test.exs +++ b/test/pleroma/instances/instance_test.exs @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Instances.InstanceTest do + alias Pleroma.Instances alias Pleroma.Instances.Instance alias Pleroma.Repo @@ -148,5 +149,13 @@ test "Handles not getting a favicon URL properly" do ) end) =~ "Instance.scrape_favicon(\"https://no-favicon.example.org/\") error: " end + + test "Doesn't scrapes unreachable instances" do + instance = insert(:instance, unreachable_since: Instances.reachability_datetime_threshold()) + url = "https://" <> instance.host + + assert capture_log(fn -> assert nil == Instance.get_or_update_favicon(URI.parse(url)) end) =~ + "Instance.scrape_favicon(\"#{url}\") ignored unreachable host" + end end end diff --git a/test/pleroma/instances_test.exs b/test/pleroma/instances_test.exs index d2618025c..5d0ce6237 100644 --- a/test/pleroma/instances_test.exs +++ b/test/pleroma/instances_test.exs @@ -32,9 +32,9 @@ test "returns `true` for host / url marked unreachable for less than `reachabili assert Instances.reachable?(URI.parse(url).host) end - test "returns true on non-binary input" do - assert Instances.reachable?(nil) - assert Instances.reachable?(1) + test "raises FunctionClauseError exception on non-binary input" do + assert_raise FunctionClauseError, fn -> Instances.reachable?(nil) end + assert_raise FunctionClauseError, fn -> Instances.reachable?(1) end end end diff --git a/test/pleroma/integration/mastodon_websocket_test.exs b/test/pleroma/integration/mastodon_websocket_test.exs index 0f2e6cc2b..4a7dbda71 100644 --- a/test/pleroma/integration/mastodon_websocket_test.exs +++ b/test/pleroma/integration/mastodon_websocket_test.exs @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Integration.MastodonWebsocketTest do + # Needs a streamer, needs to stay synchronous use Pleroma.DataCase import ExUnit.CaptureLog @@ -49,6 +50,7 @@ test "requires authentication and a valid token for protected streams" do test "allows public streams without authentication" do assert {:ok, _} = start_socket("?stream=public") assert {:ok, _} = start_socket("?stream=public:local") + assert {:ok, _} = start_socket("?stream=public:remote&instance=lain.com") assert {:ok, _} = start_socket("?stream=hashtag&tag=lain") end diff --git a/test/pleroma/keys_test.exs b/test/pleroma/keys_test.exs index 9e8528cba..55a7aa1bc 100644 --- a/test/pleroma/keys_test.exs +++ b/test/pleroma/keys_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.KeysTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Keys diff --git a/test/pleroma/list_test.exs b/test/pleroma/list_test.exs index b5572cbae..854e276f1 100644 --- a/test/pleroma/list_test.exs +++ b/test/pleroma/list_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.ListTest do alias Pleroma.Repo - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory diff --git a/test/pleroma/marker_test.exs b/test/pleroma/marker_test.exs index 7b3943c7b..3055f1ce2 100644 --- a/test/pleroma/marker_test.exs +++ b/test/pleroma/marker_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MarkerTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Marker import Pleroma.Factory diff --git a/test/pleroma/mfa/backup_codes_test.exs b/test/pleroma/mfa/backup_codes_test.exs index 41adb1e96..c3eaf40b6 100644 --- a/test/pleroma/mfa/backup_codes_test.exs +++ b/test/pleroma/mfa/backup_codes_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MFA.BackupCodesTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.MFA.BackupCodes diff --git a/test/pleroma/mfa/totp_test.exs b/test/pleroma/mfa/totp_test.exs index 9edb6fd54..8c09bf447 100644 --- a/test/pleroma/mfa/totp_test.exs +++ b/test/pleroma/mfa/totp_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MFA.TOTPTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.MFA.TOTP diff --git a/test/pleroma/mfa_test.exs b/test/pleroma/mfa_test.exs index 8875cefd9..cd1f7d0af 100644 --- a/test/pleroma/mfa_test.exs +++ b/test/pleroma/mfa_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MFATest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.MFA diff --git a/test/pleroma/migration_helper/notification_backfill_test.exs b/test/pleroma/migration_helper/notification_backfill_test.exs index 2a62a2b00..6fe8a11ac 100644 --- a/test/pleroma/migration_helper/notification_backfill_test.exs +++ b/test/pleroma/migration_helper/notification_backfill_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MigrationHelper.NotificationBackfillTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.MigrationHelper.NotificationBackfill diff --git a/test/pleroma/moderation_log_test.exs b/test/pleroma/moderation_log_test.exs index 59f4d67f8..d1e0e1e6b 100644 --- a/test/pleroma/moderation_log_test.exs +++ b/test/pleroma/moderation_log_test.exs @@ -6,7 +6,7 @@ defmodule Pleroma.ModerationLogTest do alias Pleroma.Activity alias Pleroma.ModerationLog - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory @@ -182,11 +182,14 @@ test "logging relay unfollow", %{moderator: moderator} do end test "logging report update", %{moderator: moderator} do + user = insert(:user) + report = %Activity{ id: "9m9I1F4p8ftrTP6QTI", data: %{ "type" => "Flag", - "state" => "resolved" + "state" => "resolved", + "actor" => user.ap_id } } @@ -194,35 +197,48 @@ test "logging report update", %{moderator: moderator} do ModerationLog.insert_log(%{ actor: moderator, action: "report_update", - subject: report + subject: report, + subject_actor: user }) log = Repo.one(ModerationLog) assert log.data["message"] == - "@#{moderator.nickname} updated report ##{report.id} with 'resolved' state" + "@#{moderator.nickname} updated report ##{report.id} (on user @#{user.nickname}) with 'resolved' state" end test "logging report response", %{moderator: moderator} do + user = insert(:user) + report = %Activity{ id: "9m9I1F4p8ftrTP6QTI", data: %{ - "type" => "Note" + "type" => "Note", + "actor" => user.ap_id } } - {:ok, _} = - ModerationLog.insert_log(%{ - actor: moderator, - action: "report_note", - subject: report, - text: "look at this" - }) + attrs = %{ + actor: moderator, + action: "report_note", + subject: report, + text: "look at this" + } - log = Repo.one(ModerationLog) + {:ok, log1} = ModerationLog.insert_log(attrs) + log = Repo.get(ModerationLog, log1.id) assert log.data["message"] == "@#{moderator.nickname} added note 'look at this' to report ##{report.id}" + + {:ok, log2} = ModerationLog.insert_log(Map.merge(attrs, %{subject_actor: user})) + + log = Repo.get(ModerationLog, log2.id) + + assert log.data["message"] == + "@#{moderator.nickname} added note 'look at this' to report ##{report.id} on user @#{ + user.nickname + }" end test "logging status sensitivity update", %{moderator: moderator} do diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 0e9630f28..a6558f995 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -32,6 +32,19 @@ test "never returns nil" do refute {:ok, [nil]} == Notification.create_notifications(activity) end + test "creates a notification for a report" do + reporting_user = insert(:user) + reported_user = insert(:user) + {:ok, moderator_user} = insert(:user) |> User.admin_api_update(%{is_moderator: true}) + + {:ok, activity} = CommonAPI.report(reporting_user, %{account_id: reported_user.id}) + + {:ok, [notification]} = Notification.create_notifications(activity) + + assert notification.user_id == moderator_user.id + assert notification.type == "pleroma:report" + end + test "creates a notification for an emoji reaction" do user = insert(:user) other_user = insert(:user) @@ -229,7 +242,7 @@ test "notification created if user is muted without notifications" do muter = insert(:user) muted = insert(:user) - {:ok, _user_relationships} = User.mute(muter, muted, false) + {:ok, _user_relationships} = User.mute(muter, muted, %{notifications: false}) {:ok, activity} = CommonAPI.post(muted, %{status: "Hi @#{muter.nickname}"}) @@ -400,7 +413,7 @@ test "dismisses the notification on follow request rejection" do user = insert(:user, is_locked: true) follower = insert(:user) {:ok, _, _, _follow_activity} = CommonAPI.follow(follower, user) - assert [notification] = Notification.for_user(user) + assert [_notification] = Notification.for_user(user) {:ok, _follower} = CommonAPI.reject_follow_request(follower, user) assert [] = Notification.for_user(user) end @@ -766,7 +779,7 @@ test "it returns following domain-blocking recipient in enabled recipients list" other_user = insert(:user) {:ok, other_user} = User.block_domain(other_user, blocked_domain) - {:ok, other_user} = User.follow(other_user, user) + {:ok, other_user, user} = User.follow(other_user, user) {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"}) @@ -1015,7 +1028,7 @@ test "move activity generates a notification" do test "it returns notifications for muted user without notifications", %{user: user} do muted = insert(:user) - {:ok, _user_relationships} = User.mute(user, muted, false) + {:ok, _user_relationships} = User.mute(user, muted, %{notifications: false}) {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"}) @@ -1057,7 +1070,7 @@ test "it returns notifications for domain-blocked but followed user" do blocked = insert(:user, ap_id: "http://some-domain.com") {:ok, user} = User.block_domain(user, "some-domain.com") - {:ok, _} = User.follow(user, blocked) + {:ok, _, _} = User.follow(user, blocked) {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"}) diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index 14d2c645f..7df6af7fe 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -21,6 +21,17 @@ defmodule Pleroma.Object.FetcherTest do %{method: :get, url: "https://mastodon.example.org/users/userisgone404"} -> %Tesla.Env{status: 404} + %{ + method: :get, + url: + "https://patch.cx/media/03ca3c8b4ac3ddd08bf0f84be7885f2f88de0f709112131a22d83650819e36c2.json" + } -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/json"}], + body: File.read!("test/fixtures/spoofed-object.json") + } + env -> apply(HttpRequestMock, :request, [env]) end) @@ -34,19 +45,22 @@ defmodule Pleroma.Object.FetcherTest do %{method: :get, url: "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json") + body: File.read!("test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json"), + headers: HttpRequestMock.activitypub_object_headers() } %{method: :get, url: "https://social.sakamoto.gq/users/eal"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/fetch_mocks/eal.json") + body: File.read!("test/fixtures/fetch_mocks/eal.json"), + headers: HttpRequestMock.activitypub_object_headers() } %{method: :get, url: "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/fetch_mocks/104410921027210069.json") + body: File.read!("test/fixtures/fetch_mocks/104410921027210069.json"), + headers: HttpRequestMock.activitypub_object_headers() } %{method: :get, url: "https://busshi.moe/users/tuxcrafting"} -> @@ -132,6 +146,13 @@ test "Return MRF reason when fetched status is rejected by one" do "http://mastodon.example.org/@admin/99541947525187367" ) end + + test "it does not fetch a spoofed object uploaded on an instance as an attachment" do + assert {:error, _} = + Fetcher.fetch_object_from_id( + "https://patch.cx/media/03ca3c8b4ac3ddd08bf0f84be7885f2f88de0f709112131a22d83650819e36c2.json" + ) + end end describe "implementation quirks" do diff --git a/test/pleroma/object_test.exs b/test/pleroma/object_test.exs index 99caba336..5d4e6fb84 100644 --- a/test/pleroma/object_test.exs +++ b/test/pleroma/object_test.exs @@ -281,7 +281,11 @@ test "does not fetch unknown objects when fetch_remote is false" do setup do mock(fn %{method: :get, url: "https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d"} -> - %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/poll_original.json")} + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/poll_original.json"), + headers: HttpRequestMock.activitypub_object_headers() + } env -> apply(HttpRequestMock, :request, [env]) @@ -315,7 +319,8 @@ test "refetches if the time since the last refetch is greater than the interval" mock_modified.(%Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/poll_modified.json") + body: File.read!("test/fixtures/tesla_mock/poll_modified.json"), + headers: HttpRequestMock.activitypub_object_headers() }) updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1) @@ -359,7 +364,8 @@ test "does not refetch if the time since the last refetch is greater than the in mock_modified.(%Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/poll_modified.json") + body: File.read!("test/fixtures/tesla_mock/poll_modified.json"), + headers: HttpRequestMock.activitypub_object_headers() }) updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: 100) @@ -387,7 +393,8 @@ test "preserves internal fields on refetch", %{mock_modified: mock_modified} do mock_modified.(%Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/poll_modified.json") + body: File.read!("test/fixtures/tesla_mock/poll_modified.json"), + headers: HttpRequestMock.activitypub_object_headers() }) updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1) diff --git a/test/pleroma/pagination_test.exs b/test/pleroma/pagination_test.exs index e526f23e8..5ee1e60ae 100644 --- a/test/pleroma/pagination_test.exs +++ b/test/pleroma/pagination_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.PaginationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory diff --git a/test/pleroma/registration_test.exs b/test/pleroma/registration_test.exs index 7db8e3664..462ab452b 100644 --- a/test/pleroma/registration_test.exs +++ b/test/pleroma/registration_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.RegistrationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory diff --git a/test/pleroma/repo/migrations/fix_legacy_tags_test.exs b/test/pleroma/repo/migrations/fix_legacy_tags_test.exs index 432055e45..adfed1142 100644 --- a/test/pleroma/repo/migrations/fix_legacy_tags_test.exs +++ b/test/pleroma/repo/migrations/fix_legacy_tags_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Repo.Migrations.FixLegacyTagsTest do alias Pleroma.User - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory import Pleroma.Tests.Helpers diff --git a/test/pleroma/repo/migrations/move_welcome_settings_test.exs b/test/pleroma/repo/migrations/move_welcome_settings_test.exs index 53d05a55a..5dbe9d7b0 100644 --- a/test/pleroma/repo/migrations/move_welcome_settings_test.exs +++ b/test/pleroma/repo/migrations/move_welcome_settings_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Repo.Migrations.MoveWelcomeSettingsTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory import Pleroma.Tests.Helpers alias Pleroma.ConfigDB diff --git a/test/pleroma/repo_test.exs b/test/pleroma/repo_test.exs index 155791be2..eaddef3a6 100644 --- a/test/pleroma/repo_test.exs +++ b/test/pleroma/repo_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.RepoTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.User diff --git a/test/pleroma/report_note_test.exs b/test/pleroma/report_note_test.exs index 25c1d6a61..cc4561eea 100644 --- a/test/pleroma/report_note_test.exs +++ b/test/pleroma/report_note_test.exs @@ -4,7 +4,7 @@ defmodule Pleroma.ReportNoteTest do alias Pleroma.ReportNote - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory test "create/3" do diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index 8df63de65..0a2c169ce 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -3,8 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxyTest do - use Pleroma.Web.ConnCase, async: true - + use Pleroma.Web.ConnCase import ExUnit.CaptureLog import Mox diff --git a/test/pleroma/safe_jsonb_set_test.exs b/test/pleroma/safe_jsonb_set_test.exs index 8b1274545..6d70f1026 100644 --- a/test/pleroma/safe_jsonb_set_test.exs +++ b/test/pleroma/safe_jsonb_set_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.SafeJsonbSetTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true test "it doesn't wipe the object when asked to set the value to NULL" do assert %{rows: [[%{"key" => "value", "test" => nil}]]} = diff --git a/test/pleroma/stats_test.exs b/test/pleroma/stats_test.exs index 74bf785b0..6c2fd5726 100644 --- a/test/pleroma/stats_test.exs +++ b/test/pleroma/stats_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.StatsTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory diff --git a/test/pleroma/upload/filter/dedupe_test.exs b/test/pleroma/upload/filter/dedupe_test.exs index 92a3d7df3..6559cbb50 100644 --- a/test/pleroma/upload/filter/dedupe_test.exs +++ b/test/pleroma/upload/filter/dedupe_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload.Filter.DedupeTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Upload alias Pleroma.Upload.Filter.Dedupe diff --git a/test/pleroma/upload/filter/exiftool_test.exs b/test/pleroma/upload/filter/exiftool_test.exs index 6b978b64c..b5a5ba18d 100644 --- a/test/pleroma/upload/filter/exiftool_test.exs +++ b/test/pleroma/upload/filter/exiftool_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload.Filter.ExiftoolTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Upload.Filter test "apply exiftool filter" do diff --git a/test/pleroma/uploaders/local_test.exs b/test/pleroma/uploaders/local_test.exs index 1ce7be485..5b377d580 100644 --- a/test/pleroma/uploaders/local_test.exs +++ b/test/pleroma/uploaders/local_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Uploaders.LocalTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Uploaders.Local describe "get_file/1" do diff --git a/test/pleroma/user/backup_test.exs b/test/pleroma/user/backup_test.exs new file mode 100644 index 000000000..f68e4a029 --- /dev/null +++ b/test/pleroma/user/backup_test.exs @@ -0,0 +1,244 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.BackupTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase + + import Mock + import Pleroma.Factory + import Swoosh.TestAssertions + + alias Pleroma.Bookmark + alias Pleroma.Tests.ObanHelpers + alias Pleroma.User.Backup + alias Pleroma.Web.CommonAPI + alias Pleroma.Workers.BackupWorker + + setup do + clear_config([Pleroma.Upload, :uploader]) + clear_config([Backup, :limit_days]) + clear_config([Pleroma.Emails.Mailer, :enabled], true) + end + + test "it requries enabled email" do + Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false) + user = insert(:user) + assert {:error, "Backups require enabled email"} == Backup.create(user) + end + + test "it requries user's email" do + user = insert(:user, %{email: nil}) + assert {:error, "Email is required"} == Backup.create(user) + end + + test "it creates a backup record and an Oban job" do + %{id: user_id} = user = insert(:user) + assert {:ok, %Oban.Job{args: args}} = Backup.create(user) + assert_enqueued(worker: BackupWorker, args: args) + + backup = Backup.get(args["backup_id"]) + assert %Backup{user_id: ^user_id, processed: false, file_size: 0} = backup + end + + test "it return an error if the export limit is over" do + %{id: user_id} = user = insert(:user) + limit_days = Pleroma.Config.get([Backup, :limit_days]) + assert {:ok, %Oban.Job{args: args}} = Backup.create(user) + backup = Backup.get(args["backup_id"]) + assert %Backup{user_id: ^user_id, processed: false, file_size: 0} = backup + + assert Backup.create(user) == {:error, "Last export was less than #{limit_days} days ago"} + end + + test "it process a backup record" do + Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local) + %{id: user_id} = user = insert(:user) + + assert {:ok, %Oban.Job{args: %{"backup_id" => backup_id} = args}} = Backup.create(user) + assert {:ok, backup} = perform_job(BackupWorker, args) + assert backup.file_size > 0 + assert %Backup{id: ^backup_id, processed: true, user_id: ^user_id} = backup + + delete_job_args = %{"op" => "delete", "backup_id" => backup_id} + + assert_enqueued(worker: BackupWorker, args: delete_job_args) + assert {:ok, backup} = perform_job(BackupWorker, delete_job_args) + refute Backup.get(backup_id) + + email = Pleroma.Emails.UserEmail.backup_is_ready_email(backup) + + assert_email_sent( + to: {user.name, user.email}, + html_body: email.html_body + ) + end + + test "it removes outdated backups after creating a fresh one" do + Pleroma.Config.put([Backup, :limit_days], -1) + Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local) + user = insert(:user) + + assert {:ok, job1} = Backup.create(user) + + assert {:ok, %Backup{}} = ObanHelpers.perform(job1) + assert {:ok, job2} = Backup.create(user) + assert Pleroma.Repo.aggregate(Backup, :count) == 2 + assert {:ok, backup2} = ObanHelpers.perform(job2) + + ObanHelpers.perform_all() + + assert [^backup2] = Pleroma.Repo.all(Backup) + end + + test "it creates a zip archive with user data" do + user = insert(:user, %{nickname: "cofe", name: "Cofe", ap_id: "http://cofe.io/users/cofe"}) + + {:ok, %{object: %{data: %{"id" => id1}}} = status1} = + CommonAPI.post(user, %{status: "status1"}) + + {:ok, %{object: %{data: %{"id" => id2}}} = status2} = + CommonAPI.post(user, %{status: "status2"}) + + {:ok, %{object: %{data: %{"id" => id3}}} = status3} = + CommonAPI.post(user, %{status: "status3"}) + + CommonAPI.favorite(user, status1.id) + CommonAPI.favorite(user, status2.id) + + Bookmark.create(user.id, status2.id) + Bookmark.create(user.id, status3.id) + + assert {:ok, backup} = user |> Backup.new() |> Repo.insert() + assert {:ok, path} = Backup.export(backup) + assert {:ok, zipfile} = :zip.zip_open(String.to_charlist(path), [:memory]) + assert {:ok, {'actor.json', json}} = :zip.zip_get('actor.json', zipfile) + + assert %{ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "http://localhost:4001/schemas/litepub-0.1.jsonld", + %{"@language" => "und"} + ], + "bookmarks" => "bookmarks.json", + "followers" => "http://cofe.io/users/cofe/followers", + "following" => "http://cofe.io/users/cofe/following", + "id" => "http://cofe.io/users/cofe", + "inbox" => "http://cofe.io/users/cofe/inbox", + "likes" => "likes.json", + "name" => "Cofe", + "outbox" => "http://cofe.io/users/cofe/outbox", + "preferredUsername" => "cofe", + "publicKey" => %{ + "id" => "http://cofe.io/users/cofe#main-key", + "owner" => "http://cofe.io/users/cofe" + }, + "type" => "Person", + "url" => "http://cofe.io/users/cofe" + } = Jason.decode!(json) + + assert {:ok, {'outbox.json', json}} = :zip.zip_get('outbox.json', zipfile) + + assert %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "outbox.json", + "orderedItems" => [ + %{ + "object" => %{ + "actor" => "http://cofe.io/users/cofe", + "content" => "status1", + "type" => "Note" + }, + "type" => "Create" + }, + %{ + "object" => %{ + "actor" => "http://cofe.io/users/cofe", + "content" => "status2" + } + }, + %{ + "actor" => "http://cofe.io/users/cofe", + "object" => %{ + "content" => "status3" + } + } + ], + "totalItems" => 3, + "type" => "OrderedCollection" + } = Jason.decode!(json) + + assert {:ok, {'likes.json', json}} = :zip.zip_get('likes.json', zipfile) + + assert %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "likes.json", + "orderedItems" => [^id1, ^id2], + "totalItems" => 2, + "type" => "OrderedCollection" + } = Jason.decode!(json) + + assert {:ok, {'bookmarks.json', json}} = :zip.zip_get('bookmarks.json', zipfile) + + assert %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "bookmarks.json", + "orderedItems" => [^id2, ^id3], + "totalItems" => 2, + "type" => "OrderedCollection" + } = Jason.decode!(json) + + :zip.zip_close(zipfile) + File.rm!(path) + end + + describe "it uploads and deletes a backup archive" do + setup do + clear_config(Pleroma.Uploaders.S3, + bucket: "test_bucket", + public_endpoint: "https://s3.amazonaws.com" + ) + + clear_config([Pleroma.Upload, :uploader]) + + user = insert(:user, %{nickname: "cofe", name: "Cofe", ap_id: "http://cofe.io/users/cofe"}) + + {:ok, status1} = CommonAPI.post(user, %{status: "status1"}) + {:ok, status2} = CommonAPI.post(user, %{status: "status2"}) + {:ok, status3} = CommonAPI.post(user, %{status: "status3"}) + CommonAPI.favorite(user, status1.id) + CommonAPI.favorite(user, status2.id) + Bookmark.create(user.id, status2.id) + Bookmark.create(user.id, status3.id) + + assert {:ok, backup} = user |> Backup.new() |> Repo.insert() + assert {:ok, path} = Backup.export(backup) + + [path: path, backup: backup] + end + + test "S3", %{path: path, backup: backup} do + Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.S3) + + with_mock ExAws, + request: fn + %{http_method: :put} -> {:ok, :ok} + %{http_method: :delete} -> {:ok, %{status_code: 204}} + end do + assert {:ok, %Pleroma.Upload{}} = Backup.upload(backup, path) + assert {:ok, _backup} = Backup.delete(backup) + end + + with_mock ExAws, request: fn %{http_method: :delete} -> {:ok, %{status_code: 204}} end do + end + end + + test "Local", %{path: path, backup: backup} do + Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local) + + assert {:ok, %Pleroma.Upload{}} = Backup.upload(backup, path) + assert {:ok, _backup} = Backup.delete(backup) + end + end +end diff --git a/test/pleroma/user/import_test.exs b/test/pleroma/user/import_test.exs index e404deeb5..e198cdc08 100644 --- a/test/pleroma/user/import_test.exs +++ b/test/pleroma/user/import_test.exs @@ -30,7 +30,7 @@ test "it imports user followings from list" do assert {:ok, result} = ObanHelpers.perform(job) assert is_list(result) - assert result == [user2, user3] + assert result == [refresh_record(user2), refresh_record(user3)] assert User.following?(user1, user2) assert User.following?(user1, user3) end diff --git a/test/pleroma/user/notification_setting_test.exs b/test/pleroma/user/notification_setting_test.exs index 308da216a..701130380 100644 --- a/test/pleroma/user/notification_setting_test.exs +++ b/test/pleroma/user/notification_setting_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User.NotificationSettingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.User.NotificationSetting diff --git a/test/pleroma/user_relationship_test.exs b/test/pleroma/user_relationship_test.exs index f12406097..da4982065 100644 --- a/test/pleroma/user_relationship_test.exs +++ b/test/pleroma/user_relationship_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.UserRelationshipTest do alias Pleroma.UserRelationship - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory diff --git a/test/pleroma/user_search_test.exs b/test/pleroma/user_search_test.exs index c4b805005..accb0b816 100644 --- a/test/pleroma/user_search_test.exs +++ b/test/pleroma/user_search_test.exs @@ -65,12 +65,13 @@ test "excludes invisible users from results" do assert found_user.id == user.id end - test "excludes users when discoverable is false" do - insert(:user, %{nickname: "john 3000", discoverable: false}) + # Note: as in Mastodon, `is_discoverable` doesn't anyhow relate to user searchability + test "includes non-discoverable users in results" do + insert(:user, %{nickname: "john 3000", is_discoverable: false}) insert(:user, %{nickname: "john 3001"}) users = User.search("john") - assert Enum.count(users) == 1 + assert Enum.count(users) == 2 end test "excludes service actors from results" do @@ -150,8 +151,8 @@ test "finds users, boosting ranks of friends and followers" do follower = insert(:user, %{name: "Doe"}) friend = insert(:user, %{name: "Doe"}) - {:ok, follower} = User.follow(follower, u1) - {:ok, u1} = User.follow(u1, friend) + {:ok, follower, u1} = User.follow(follower, u1) + {:ok, u1, friend} = User.follow(u1, friend) assert [friend.id, follower.id, u2.id] -- Enum.map(User.search("doe", resolve: false, for_user: u1), & &1.id) == [] @@ -164,9 +165,9 @@ test "finds followings of user by partial name" do following_jimi = insert(:user, %{name: "Lizz Wright"}) follower_lizz = insert(:user, %{name: "Jimi"}) - {:ok, lizz} = User.follow(lizz, following_lizz) - {:ok, _jimi} = User.follow(jimi, following_jimi) - {:ok, _follower_lizz} = User.follow(follower_lizz, lizz) + {:ok, lizz, following_lizz} = User.follow(lizz, following_lizz) + {:ok, _jimi, _following_jimi} = User.follow(jimi, following_jimi) + {:ok, _follower_lizz, _lizz} = User.follow(follower_lizz, lizz) assert Enum.map(User.search("jimi", following: true, for_user: lizz), & &1.id) == [ following_lizz.id diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index d8ac652af..40bbcad0b 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -233,7 +233,7 @@ test "follow_all follows mutliple users" do {:ok, _user_relationship} = User.block(user, blocked) {:ok, _user_relationship} = User.block(reverse_blocked, user) - {:ok, user} = User.follow(user, followed_zero) + {:ok, user, followed_zero} = User.follow(user, followed_zero) {:ok, user} = User.follow_all(user, [followed_one, followed_two, blocked, reverse_blocked]) @@ -262,7 +262,7 @@ test "follow takes a user and another user" do user = insert(:user) followed = insert(:user) - {:ok, user} = User.follow(user, followed) + {:ok, user, followed} = User.follow(user, followed) user = User.get_cached_by_id(user.id) followed = User.get_cached_by_ap_id(followed.ap_id) @@ -302,7 +302,7 @@ test "local users do not automatically follow local locked accounts" do follower = insert(:user, is_locked: true) followed = insert(:user, is_locked: true) - {:ok, follower} = User.maybe_direct_follow(follower, followed) + {:ok, follower, followed} = User.maybe_direct_follow(follower, followed) refute User.following?(follower, followed) end @@ -330,7 +330,7 @@ test "unfollow with syncronizes external user" do following_address: "http://localhost:4001/users/fuser2/following" }) - {:ok, user} = User.follow(user, followed, :follow_accept) + {:ok, user, followed} = User.follow(user, followed, :follow_accept) {:ok, user, _activity} = User.unfollow(user, followed) @@ -343,7 +343,7 @@ test "unfollow takes a user and another user" do followed = insert(:user) user = insert(:user) - {:ok, user} = User.follow(user, followed, :follow_accept) + {:ok, user, followed} = User.follow(user, followed, :follow_accept) assert User.following(user) == [user.follower_address, followed.follower_address] @@ -388,6 +388,7 @@ test "fetches correct profile for nickname beginning with number" do } setup do: clear_config([:instance, :autofollowed_nicknames]) + setup do: clear_config([:instance, :autofollowing_nicknames]) setup do: clear_config([:welcome]) setup do: clear_config([:instance, :account_activation_required]) @@ -408,6 +409,23 @@ test "it autofollows accounts that are set for it" do refute User.following?(registered_user, remote_user) end + test "it adds automatic followers for new registered accounts" do + user1 = insert(:user) + user2 = insert(:user) + + Pleroma.Config.put([:instance, :autofollowing_nicknames], [ + user1.nickname, + user2.nickname + ]) + + cng = User.register_changeset(%User{}, @full_user_data) + + {:ok, registered_user} = User.register(cng) + + assert User.following?(user1, registered_user) + assert User.following?(user2, registered_user) + end + test "it sends a welcome message if it is set" do welcome_user = insert(:user) Pleroma.Config.put([:welcome, :direct_message, :enabled], true) @@ -517,6 +535,22 @@ test "it sends a confirm email" do |> assert_email_sent() end + test "sends a pending approval email" do + clear_config([:instance, :account_approval_required], true) + + {:ok, user} = + User.register_changeset(%User{}, @full_user_data) + |> User.register() + + ObanHelpers.perform_all() + + assert_email_sent( + from: Pleroma.Config.Helpers.sender(), + to: {user.name, user.email}, + subject: "Your account is awaiting approval" + ) + end + test "it requires an email, name, nickname and password, bio is optional when account_activation_required is enabled" do Pleroma.Config.put([:instance, :account_activation_required], true) @@ -877,6 +911,13 @@ test "it has required fields" do refute cs.valid? end) end + + test "it is invalid given a local user" do + user = insert(:user) + cs = User.remote_user_changeset(user, %{name: "tom from myspace"}) + + refute cs.valid? + end end describe "followers and friends" do @@ -886,8 +927,8 @@ test "gets all followers for a given user" do follower_two = insert(:user) not_follower = insert(:user) - {:ok, follower_one} = User.follow(follower_one, user) - {:ok, follower_two} = User.follow(follower_two, user) + {:ok, follower_one, user} = User.follow(follower_one, user) + {:ok, follower_two, user} = User.follow(follower_two, user) res = User.get_followers(user) @@ -902,8 +943,8 @@ test "gets all friends (followed users) for a given user" do followed_two = insert(:user) not_followed = insert(:user) - {:ok, user} = User.follow(user, followed_one) - {:ok, user} = User.follow(user, followed_two) + {:ok, user, followed_one} = User.follow(user, followed_one) + {:ok, user, followed_two} = User.follow(user, followed_two) res = User.get_friends(user) @@ -990,6 +1031,27 @@ test "it mutes people" do assert User.muted_notifications?(user, muted_user) end + test "expiring" do + user = insert(:user) + muted_user = insert(:user) + + {:ok, _user_relationships} = User.mute(user, muted_user, %{expires_in: 60}) + assert User.mutes?(user, muted_user) + + worker = Pleroma.Workers.MuteExpireWorker + args = %{"op" => "unmute_user", "muter_id" => user.id, "mutee_id" => muted_user.id} + + assert_enqueued( + worker: worker, + args: args + ) + + assert :ok = perform_job(worker, args) + + refute User.mutes?(user, muted_user) + refute User.muted_notifications?(user, muted_user) + end + test "it unmutes users" do user = insert(:user) muted_user = insert(:user) @@ -1001,6 +1063,17 @@ test "it unmutes users" do refute User.muted_notifications?(user, muted_user) end + test "it unmutes users by id" do + user = insert(:user) + muted_user = insert(:user) + + {:ok, _user_relationships} = User.mute(user, muted_user) + {:ok, _user_mute} = User.unmute(user.id, muted_user.id) + + refute User.mutes?(user, muted_user) + refute User.muted_notifications?(user, muted_user) + end + test "it mutes user without notifications" do user = insert(:user) muted_user = insert(:user) @@ -1008,7 +1081,7 @@ test "it mutes user without notifications" do refute User.mutes?(user, muted_user) refute User.muted_notifications?(user, muted_user) - {:ok, _user_relationships} = User.mute(user, muted_user, false) + {:ok, _user_relationships} = User.mute(user, muted_user, %{notifications: false}) assert User.mutes?(user, muted_user) refute User.muted_notifications?(user, muted_user) @@ -1041,8 +1114,8 @@ test "blocks tear down cyclical follow relationships" do blocker = insert(:user) blocked = insert(:user) - {:ok, blocker} = User.follow(blocker, blocked) - {:ok, blocked} = User.follow(blocked, blocker) + {:ok, blocker, blocked} = User.follow(blocker, blocked) + {:ok, blocked, blocker} = User.follow(blocked, blocker) assert User.following?(blocker, blocked) assert User.following?(blocked, blocker) @@ -1060,7 +1133,7 @@ test "blocks tear down blocker->blocked follow relationships" do blocker = insert(:user) blocked = insert(:user) - {:ok, blocker} = User.follow(blocker, blocked) + {:ok, blocker, blocked} = User.follow(blocker, blocked) assert User.following?(blocker, blocked) refute User.following?(blocked, blocker) @@ -1078,7 +1151,7 @@ test "blocks tear down blocked->blocker follow relationships" do blocker = insert(:user) blocked = insert(:user) - {:ok, blocked} = User.follow(blocked, blocker) + {:ok, blocked, blocker} = User.follow(blocked, blocker) refute User.following?(blocker, blocked) assert User.following?(blocked, blocker) @@ -1176,7 +1249,7 @@ test "follows take precedence over domain blocks" do good_eggo = insert(:user, %{ap_id: "https://meanies.social/user/cuteposter"}) {:ok, user} = User.block_domain(user, "meanies.social") - {:ok, user} = User.follow(user, good_eggo) + {:ok, user, good_eggo} = User.follow(user, good_eggo) refute User.blocks?(user, good_eggo) end @@ -1210,8 +1283,8 @@ test "get recipients" do assert Enum.map([actor, addressed], & &1.ap_id) -- Enum.map(User.get_recipients_from_activity(activity), & &1.ap_id) == [] - {:ok, user} = User.follow(user, actor) - {:ok, _user_two} = User.follow(user_two, actor) + {:ok, user, actor} = User.follow(user, actor) + {:ok, _user_two, _actor} = User.follow(user_two, actor) recipients = User.get_recipients_from_activity(activity) assert length(recipients) == 3 assert user in recipients @@ -1232,8 +1305,8 @@ test "has following" do assert Enum.map([actor, addressed], & &1.ap_id) -- Enum.map(User.get_recipients_from_activity(activity), & &1.ap_id) == [] - {:ok, _actor} = User.follow(actor, user) - {:ok, _actor} = User.follow(actor, user_two) + {:ok, _actor, _user} = User.follow(actor, user) + {:ok, _actor, _user_two} = User.follow(actor, user_two) recipients = User.get_recipients_from_activity(activity) assert length(recipients) == 2 assert addressed in recipients @@ -1254,7 +1327,7 @@ test "hide a user from followers" do user = insert(:user) user2 = insert(:user) - {:ok, user} = User.follow(user, user2) + {:ok, user, user2} = User.follow(user, user2) {:ok, _user} = User.deactivate(user) user2 = User.get_cached_by_id(user2.id) @@ -1267,7 +1340,7 @@ test "hide a user from friends" do user = insert(:user) user2 = insert(:user) - {:ok, user2} = User.follow(user2, user) + {:ok, user2, user} = User.follow(user2, user) assert user2.following_count == 1 assert User.following_count(user2) == 1 @@ -1285,7 +1358,7 @@ test "hide a user's statuses from timelines and notifications" do user = insert(:user) user2 = insert(:user) - {:ok, user2} = User.follow(user2, user) + {:ok, user2, user} = User.follow(user2, user) {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{user2.nickname}"}) @@ -1336,6 +1409,98 @@ test "approves a list of users" do assert false == user.approval_pending end) end + + test "it sends welcome email if it is set" do + clear_config([:welcome, :email, :enabled], true) + clear_config([:welcome, :email, :sender], "tester@test.me") + + user = insert(:user, approval_pending: true) + welcome_user = insert(:user, email: "tester@test.me") + instance_name = Pleroma.Config.get([:instance, :name]) + + User.approve(user) + + ObanHelpers.perform_all() + + assert_email_sent( + from: {instance_name, welcome_user.email}, + to: {user.name, user.email}, + html_body: "Welcome to #{instance_name}" + ) + end + + test "approving an approved user does not trigger post-register actions" do + clear_config([:welcome, :email, :enabled], true) + + user = insert(:user, approval_pending: false) + User.approve(user) + + ObanHelpers.perform_all() + + assert_no_email_sent() + end + end + + describe "confirm" do + test "confirms a user" do + user = insert(:user, confirmation_pending: true) + assert true == user.confirmation_pending + {:ok, user} = User.confirm(user) + assert false == user.confirmation_pending + end + + test "confirms a list of users" do + unconfirmed_users = [ + insert(:user, confirmation_pending: true), + insert(:user, confirmation_pending: true), + insert(:user, confirmation_pending: true) + ] + + {:ok, users} = User.confirm(unconfirmed_users) + + assert Enum.count(users) == 3 + + Enum.each(users, fn user -> + assert false == user.confirmation_pending + end) + end + + test "sends approval emails when `approval_pending: true`" do + admin = insert(:user, is_admin: true) + user = insert(:user, confirmation_pending: true, approval_pending: true) + User.confirm(user) + + ObanHelpers.perform_all() + + user_email = Pleroma.Emails.UserEmail.approval_pending_email(user) + admin_email = Pleroma.Emails.AdminEmail.new_unapproved_registration(admin, user) + + notify_email = Pleroma.Config.get([:instance, :notify_email]) + instance_name = Pleroma.Config.get([:instance, :name]) + + # User approval email + assert_email_sent( + from: {instance_name, notify_email}, + to: {user.name, user.email}, + html_body: user_email.html_body + ) + + # Admin email + assert_email_sent( + from: {instance_name, notify_email}, + to: {admin.name, admin.email}, + html_body: admin_email.html_body + ) + end + + test "confirming a confirmed user does not trigger post-register actions" do + user = insert(:user, confirmation_pending: false, approval_pending: true) + User.confirm(user) + + ObanHelpers.perform_all() + + assert_no_email_sent() + end end describe "delete" do @@ -1358,10 +1523,10 @@ test ".delete_user_activities deletes all create activities", %{user: user} do test "it deactivates a user, all follow relationships and all activities", %{user: user} do follower = insert(:user) - {:ok, follower} = User.follow(follower, user) + {:ok, follower, user} = User.follow(follower, user) locked_user = insert(:user, name: "locked", is_locked: true) - {:ok, _} = User.follow(user, locked_user, :follow_pending) + {:ok, _, _} = User.follow(user, locked_user, :follow_pending) object = insert(:note, user: user) activity = insert(:note_activity, user: user, note: object) @@ -1467,7 +1632,7 @@ test "delete/1 purges a user when they wouldn't be fully deleted" do pleroma_settings_store: %{"q" => "x"}, fields: [%{"gg" => "qq"}], raw_fields: [%{"gg" => "qq"}], - discoverable: true, + is_discoverable: true, also_known_as: ["https://lol.olo/users/loll"] }) @@ -1509,7 +1674,7 @@ test "delete/1 purges a user when they wouldn't be fully deleted" do pleroma_settings_store: %{}, fields: [], raw_fields: [], - discoverable: false, + is_discoverable: false, also_known_as: [] } = user end @@ -1719,9 +1884,9 @@ test "follower count is updated when a follower is blocked" do follower2 = insert(:user) follower3 = insert(:user) - {:ok, follower} = User.follow(follower, user) - {:ok, _follower2} = User.follow(follower2, user) - {:ok, _follower3} = User.follow(follower3, user) + {:ok, follower, user} = User.follow(follower, user) + {:ok, _follower2, _user} = User.follow(follower2, user) + {:ok, _follower3, _user} = User.follow(follower3, user) {:ok, _user_relationship} = User.block(user, follower) user = refresh_record(user) @@ -1832,24 +1997,6 @@ test "Only includes users with no read notifications" do end end - describe "toggle_confirmation/1" do - test "if user is confirmed" do - user = insert(:user, confirmation_pending: false) - {:ok, user} = User.toggle_confirmation(user) - - assert user.confirmation_pending - assert user.confirmation_token - end - - test "if user is unconfirmed" do - user = insert(:user, confirmation_pending: true, confirmation_token: "some token") - {:ok, user} = User.toggle_confirmation(user) - - refute user.confirmation_pending - refute user.confirmation_token - end - end - describe "ensure_keys_present" do test "it creates keys for a user and stores them in info" do user = insert(:user) @@ -1962,8 +2109,7 @@ test "updates the counters normally on following/getting a follow when disabled" assert other_user.following_count == 0 assert other_user.follower_count == 0 - {:ok, user} = Pleroma.User.follow(user, other_user) - other_user = Pleroma.User.get_by_id(other_user.id) + {:ok, user, other_user} = Pleroma.User.follow(user, other_user) assert user.following_count == 1 assert other_user.follower_count == 1 @@ -1986,8 +2132,7 @@ test "syncronizes the counters with the remote instance for the followed when en assert other_user.follower_count == 0 Pleroma.Config.put([:instance, :external_user_synchronization], true) - {:ok, _user} = User.follow(user, other_user) - other_user = User.get_by_id(other_user.id) + {:ok, _user, other_user} = User.follow(user, other_user) assert other_user.follower_count == 437 end @@ -2009,7 +2154,7 @@ test "syncronizes the counters with the remote instance for the follower when en assert other_user.follower_count == 0 Pleroma.Config.put([:instance, :external_user_synchronization], true) - {:ok, other_user} = User.follow(other_user, user) + {:ok, other_user, _user} = User.follow(other_user, user) assert other_user.following_count == 152 end @@ -2121,4 +2266,9 @@ test "avatar fallback" do assert User.avatar_url(user, no_default: true) == nil end + + test "get_host/1" do + user = insert(:user, ap_id: "https://lain.com/users/lain", nickname: "lain") + assert User.get_host(user) == "lain.com" + end end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index b11e2f961..0063d0482 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -156,21 +156,6 @@ test "it returns error when user is not found", %{conn: conn} do assert response == "Not found" end - - test "it requires authentication if instance is NOT federating", %{ - conn: conn - } do - user = insert(:user) - - conn = - put_req_header( - conn, - "accept", - "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" - ) - - ensure_federating_or_authenticated(conn, "/users/#{user.nickname}.json", user) - end end describe "mastodon compatibility routes" do @@ -228,6 +213,23 @@ test "it returns a json representation of the activity with accept application/j end describe "/objects/:uuid" do + test "it doesn't return a local-only object", %{conn: conn} do + user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{status: "test", visibility: "local"}) + + assert Pleroma.Web.ActivityPub.Visibility.is_local_public?(post) + + object = Object.normalize(post, false) + uuid = String.split(object.data["id"], "/") |> List.last() + + conn = + conn + |> put_req_header("accept", "application/json") + |> get("/objects/#{uuid}") + + assert json_response(conn, 404) + end + test "it returns a json representation of the object with accept application/json", %{ conn: conn } do @@ -338,21 +340,25 @@ test "cached purged after object deletion", %{conn: conn} do assert "Not found" == json_response(conn2, :not_found) end - - test "it requires authentication if instance is NOT federating", %{ - conn: conn - } do - user = insert(:user) - note = insert(:note) - uuid = String.split(note.data["id"], "/") |> List.last() - - conn = put_req_header(conn, "accept", "application/activity+json") - - ensure_federating_or_authenticated(conn, "/objects/#{uuid}", user) - end end describe "/activities/:uuid" do + test "it doesn't return a local-only activity", %{conn: conn} do + user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{status: "test", visibility: "local"}) + + assert Pleroma.Web.ActivityPub.Visibility.is_local_public?(post) + + uuid = String.split(post.data["id"], "/") |> List.last() + + conn = + conn + |> put_req_header("accept", "application/json") + |> get("/activities/#{uuid}") + + assert json_response(conn, 404) + end + test "it returns a json representation of the activity", %{conn: conn} do activity = insert(:note_activity) uuid = String.split(activity.data["id"], "/") |> List.last() @@ -421,23 +427,11 @@ test "cached purged after activity deletion", %{conn: conn} do assert "Not found" == json_response(conn2, :not_found) end - - test "it requires authentication if instance is NOT federating", %{ - conn: conn - } do - user = insert(:user) - activity = insert(:note_activity) - uuid = String.split(activity.data["id"], "/") |> List.last() - - conn = put_req_header(conn, "accept", "application/activity+json") - - ensure_federating_or_authenticated(conn, "/activities/#{uuid}", user) - end end describe "/inbox" do test "it inserts an incoming activity into the database", %{conn: conn} do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() conn = conn @@ -465,7 +459,7 @@ test "it inserts an incoming activity into the database" <> data = File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", user.ap_id) |> put_in(["object", "attridbutedTo"], user.ap_id) @@ -482,7 +476,7 @@ test "it inserts an incoming activity into the database" <> end test "it clears `unreachable` federation status of the sender", %{conn: conn} do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() sender_url = data["actor"] Instances.set_consistently_unreachable(sender_url) @@ -540,8 +534,8 @@ test "accept follow activity", %{conn: conn} do test "without valid signature, " <> "it only accepts Create activities and requires enabled federation", %{conn: conn} do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() - non_create_data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + non_create_data = File.read!("test/fixtures/mastodon-announce.json") |> Jason.decode!() conn = put_req_header(conn, "content-type", "application/activity+json") @@ -570,7 +564,7 @@ test "without valid signature, " <> setup do data = File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() [data: data] end @@ -681,7 +675,7 @@ test "it accepts messages from actors that are followed by the user", %{ recipient = insert(:user) actor = insert(:user, %{ap_id: "http://mastodon.example.org/users/actor"}) - {:ok, recipient} = User.follow(recipient, actor) + {:ok, recipient, actor} = User.follow(recipient, actor) object = data["object"] @@ -753,7 +747,7 @@ test "it removes all follower collections but actor's", %{conn: conn} do data = File.read!("test/fixtures/activitypub-client-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() object = Map.put(data["object"], "attributedTo", actor.ap_id) @@ -805,6 +799,142 @@ test "it requires authentication", %{conn: conn} do assert json_response(ret_conn, 200) end + + @tag capture_log: true + test "forwarded report", %{conn: conn} do + admin = insert(:user, is_admin: true) + actor = insert(:user, local: false) + remote_domain = URI.parse(actor.ap_id).host + reported_user = insert(:user) + + note = insert(:note_activity, user: reported_user) + + data = %{ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "https://#{remote_domain}/schemas/litepub-0.1.jsonld", + %{ + "@language" => "und" + } + ], + "actor" => actor.ap_id, + "cc" => [ + reported_user.ap_id + ], + "content" => "test", + "context" => "context", + "id" => "http://#{remote_domain}/activities/02be56cf-35e3-46b4-b2c6-47ae08dfee9e", + "nickname" => reported_user.nickname, + "object" => [ + reported_user.ap_id, + %{ + "actor" => %{ + "actor_type" => "Person", + "approval_pending" => false, + "avatar" => "", + "confirmation_pending" => false, + "deactivated" => false, + "display_name" => "test user", + "id" => reported_user.id, + "local" => false, + "nickname" => reported_user.nickname, + "registration_reason" => nil, + "roles" => %{ + "admin" => false, + "moderator" => false + }, + "tags" => [], + "url" => reported_user.ap_id + }, + "content" => "", + "id" => note.data["id"], + "published" => note.data["published"], + "type" => "Note" + } + ], + "published" => note.data["published"], + "state" => "open", + "to" => [], + "type" => "Flag" + } + + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{reported_user.nickname}/inbox", data) + |> json_response(200) + + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + + assert Pleroma.Repo.aggregate(Activity, :count, :id) == 2 + + ObanHelpers.perform_all() + + Swoosh.TestAssertions.assert_email_sent( + to: {admin.name, admin.email}, + html_body: ~r/Reported Account:/i + ) + end + + @tag capture_log: true + test "forwarded report from mastodon", %{conn: conn} do + admin = insert(:user, is_admin: true) + actor = insert(:user, local: false) + remote_domain = URI.parse(actor.ap_id).host + remote_actor = "https://#{remote_domain}/actor" + [reported_user, another] = insert_list(2, :user) + + note = insert(:note_activity, user: reported_user) + + Pleroma.Web.CommonAPI.favorite(another, note.id) + + mock_json_body = + "test/fixtures/mastodon/application_actor.json" + |> File.read!() + |> String.replace("{{DOMAIN}}", remote_domain) + + Tesla.Mock.mock(fn %{url: ^remote_actor} -> + %Tesla.Env{ + status: 200, + body: mock_json_body, + headers: [{"content-type", "application/activity+json"}] + } + end) + + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "actor" => remote_actor, + "content" => "test report", + "id" => "https://#{remote_domain}/e3b12fd1-948c-446e-b93b-a5e67edbe1d8", + "nickname" => reported_user.nickname, + "object" => [ + reported_user.ap_id, + note.data["object"] + ], + "type" => "Flag" + } + + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{reported_user.nickname}/inbox", data) + |> json_response(200) + + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + + flag_activity = "Flag" |> Pleroma.Activity.Queries.by_type() |> Pleroma.Repo.one() + reported_user_ap_id = reported_user.ap_id + + [^reported_user_ap_id, flag_data] = flag_activity.data["object"] + + Enum.each(~w(actor content id published type), &Map.has_key?(flag_data, &1)) + ObanHelpers.perform_all() + + Swoosh.TestAssertions.assert_email_sent( + to: {admin.name, admin.email}, + html_body: ~r/#{note.data["object"]}/i + ) + end end describe "GET /users/:nickname/outbox" do @@ -893,15 +1023,6 @@ test "it returns an announce activity in a collection", %{conn: conn} do assert response(conn, 200) =~ announce_activity.data["object"] end - - test "it requires authentication if instance is NOT federating", %{ - conn: conn - } do - user = insert(:user) - conn = put_req_header(conn, "accept", "application/activity+json") - - ensure_federating_or_authenticated(conn, "/users/#{user.nickname}/outbox", user) - end end describe "POST /users/:nickname/outbox (C2S)" do diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 1a8a844ca..9eb7ae86b 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -505,22 +505,22 @@ test "increases replies count", %{user: user} do # public {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "public")) - assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id) assert object.data["repliesCount"] == 1 # unlisted {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "unlisted")) - assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id) assert object.data["repliesCount"] == 2 # private {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "private")) - assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id) assert object.data["repliesCount"] == 2 # direct {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "direct")) - assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) + assert %{data: _data, object: object} = Activity.get_by_ap_id_with_object(ap_id) assert object.data["repliesCount"] == 2 end end @@ -726,7 +726,7 @@ test "does return activities from followed users on blocked domains" do domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"}) blocker = insert(:user) - {:ok, blocker} = User.follow(blocker, domain_user) + {:ok, blocker, domain_user} = User.follow(blocker, domain_user) {:ok, blocker} = User.block_domain(blocker, domain) assert User.following?(blocker, domain_user) @@ -752,6 +752,22 @@ test "does return activities from followed users on blocked domains" do refute repeat_activity in activities end + test "returns your own posts regardless of mute" do + user = insert(:user) + muted = insert(:user) + + {:ok, muted_post} = CommonAPI.post(muted, %{status: "Im stupid"}) + + {:ok, reply} = + CommonAPI.post(user, %{status: "I'm muting you", in_reply_to_status_id: muted_post.id}) + + {:ok, _} = User.mute(user, muted) + + [activity] = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true}) + + assert activity.id == reply.id + end + test "doesn't return muted activities" do activity_one = insert(:note_activity) activity_two = insert(:note_activity) @@ -837,7 +853,7 @@ test "does include announces on request" do user = insert(:user) booster = insert(:user) - {:ok, user} = User.follow(user, booster) + {:ok, user, booster} = User.follow(user, booster) {:ok, announce} = CommonAPI.repeat(activity_three.id, booster) @@ -1142,13 +1158,13 @@ test "it filters broken threads" do user2 = insert(:user) user3 = insert(:user) - {:ok, user1} = User.follow(user1, user3) + {:ok, user1, user3} = User.follow(user1, user3) assert User.following?(user1, user3) - {:ok, user2} = User.follow(user2, user3) + {:ok, user2, user3} = User.follow(user2, user3) assert User.following?(user2, user3) - {:ok, user3} = User.follow(user3, user2) + {:ok, user3, user2} = User.follow(user3, user2) assert User.following?(user3, user2) {:ok, public_activity} = CommonAPI.post(user3, %{status: "hi 1"}) @@ -1282,6 +1298,31 @@ test "it can create a Flag activity", assert_called(Utils.maybe_federate(%{activity | data: new_data})) end + + test_with_mock "reverts on error", + %{ + reporter: reporter, + context: context, + target_account: target_account, + reported_activity: reported_activity, + content: content + }, + Utils, + [:passthrough], + maybe_federate: fn _ -> {:error, :reverted} end do + assert {:error, :reverted} = + ActivityPub.flag(%{ + actor: reporter, + context: context, + account: target_account, + statuses: [reported_activity], + content: content + }) + + assert Repo.aggregate(Activity, :count, :id) == 1 + assert Repo.aggregate(Object, :count, :id) == 2 + assert Repo.aggregate(Notification, :count, :id) == 0 + end end test "fetch_activities/2 returns activities addressed to a list " do @@ -1410,19 +1451,25 @@ test "doesn't crash when follower and following counters are hidden" do mock(fn env -> case env.url do "http://localhost:4001/users/masto_hidden_counters/following" -> - json(%{ - "@context" => "https://www.w3.org/ns/activitystreams", - "id" => "http://localhost:4001/users/masto_hidden_counters/followers" - }) + json( + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "http://localhost:4001/users/masto_hidden_counters/followers" + }, + headers: HttpRequestMock.activitypub_object_headers() + ) "http://localhost:4001/users/masto_hidden_counters/following?page=1" -> %Tesla.Env{status: 403, body: ""} "http://localhost:4001/users/masto_hidden_counters/followers" -> - json(%{ - "@context" => "https://www.w3.org/ns/activitystreams", - "id" => "http://localhost:4001/users/masto_hidden_counters/following" - }) + json( + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "http://localhost:4001/users/masto_hidden_counters/following" + }, + headers: HttpRequestMock.activitypub_object_headers() + ) "http://localhost:4001/users/masto_hidden_counters/followers?page=1" -> %Tesla.Env{status: 403, body: ""} @@ -1884,13 +1931,13 @@ test "home timeline with default reply_visibility `self`", %{ defp public_messages(_) do [u1, u2, u3, u4] = insert_list(4, :user) - {:ok, u1} = User.follow(u1, u2) - {:ok, u2} = User.follow(u2, u1) - {:ok, u1} = User.follow(u1, u4) - {:ok, u4} = User.follow(u4, u1) + {:ok, u1, u2} = User.follow(u1, u2) + {:ok, u2, u1} = User.follow(u2, u1) + {:ok, u1, u4} = User.follow(u1, u4) + {:ok, u4, u1} = User.follow(u4, u1) - {:ok, u2} = User.follow(u2, u3) - {:ok, u3} = User.follow(u3, u2) + {:ok, u2, u3} = User.follow(u2, u3) + {:ok, u3, u2} = User.follow(u3, u2) {:ok, a1} = CommonAPI.post(u1, %{status: "Status"}) @@ -1983,15 +2030,15 @@ defp public_messages(_) do defp private_messages(_) do [u1, u2, u3, u4] = insert_list(4, :user) - {:ok, u1} = User.follow(u1, u2) - {:ok, u2} = User.follow(u2, u1) - {:ok, u1} = User.follow(u1, u3) - {:ok, u3} = User.follow(u3, u1) - {:ok, u1} = User.follow(u1, u4) - {:ok, u4} = User.follow(u4, u1) + {:ok, u1, u2} = User.follow(u1, u2) + {:ok, u2, u1} = User.follow(u2, u1) + {:ok, u1, u3} = User.follow(u1, u3) + {:ok, u3, u1} = User.follow(u3, u1) + {:ok, u1, u4} = User.follow(u1, u4) + {:ok, u4, u1} = User.follow(u4, u1) - {:ok, u2} = User.follow(u2, u3) - {:ok, u3} = User.follow(u3, u2) + {:ok, u2, u3} = User.follow(u2, u3) + {:ok, u3, u2} = User.follow(u3, u2) {:ok, a1} = CommonAPI.post(u1, %{status: "Status", visibility: "private"}) @@ -2257,4 +2304,15 @@ test "`following` still contains self-replies by friends" do assert length(activities) == 2 end end + + test "allow fetching of accounts with an empty string name field" do + Tesla.Mock.mock(fn + %{method: :get, url: "https://princess.cat/users/mewmew"} -> + file = File.read!("test/fixtures/mewmew_no_name.json") + %Tesla.Env{status: 200, body: file, headers: HttpRequestMock.activitypub_object_headers()} + end) + + {:ok, user} = ActivityPub.make_user_from_ap_id("https://princess.cat/users/mewmew") + assert user.name == " " + end end diff --git a/test/pleroma/web/activity_pub/mrf/anti_followbot_policy_test.exs b/test/pleroma/web/activity_pub/mrf/anti_followbot_policy_test.exs index 3c795f5ac..49bbc271d 100644 --- a/test/pleroma/web/activity_pub/mrf/anti_followbot_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/anti_followbot_policy_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicyTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy diff --git a/test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs b/test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs index 9a283f27d..19ea491c0 100644 --- a/test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs +++ b/test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.Object diff --git a/test/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy_test.exs b/test/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy_test.exs index 86dd9ddae..b5f401ad2 100644 --- a/test/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicyTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy diff --git a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs index 1710c4d2a..84362ce78 100644 --- a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs @@ -3,10 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do - use Pleroma.DataCase + use ExUnit.Case + use Pleroma.Tests.Helpers alias Pleroma.HTTP - alias Pleroma.Tests.ObanHelpers alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy import Mock @@ -25,13 +25,13 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do setup do: clear_config([:media_proxy, :enabled], true) test "it prefetches media proxy URIs" do + Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} -> + {:ok, %Tesla.Env{status: 200, body: ""}} + end) + with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do MediaProxyWarmingPolicy.filter(@message) - ObanHelpers.perform_all() - # Performing jobs which has been just enqueued - ObanHelpers.perform_all() - assert called(HTTP.get(:_, :_, :_)) end end diff --git a/test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs b/test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs index 64ea61dd4..d03456b34 100644 --- a/test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy test "it clears content object" do diff --git a/test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs b/test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs index 9b39c45bd..5fccf7760 100644 --- a/test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs +++ b/test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.MRF.NormalizeMarkup @html_sample """ diff --git a/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs b/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs index cf6acc9a2..e8317b2af 100644 --- a/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/object_age_policy_test.exs @@ -22,7 +22,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do defp get_old_message do File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() end defp get_new_message do diff --git a/test/pleroma/web/activity_pub/mrf/reject_non_public_test.exs b/test/pleroma/web/activity_pub/mrf/reject_non_public_test.exs index 58b46b9a2..e08eb3ba6 100644 --- a/test/pleroma/web/activity_pub/mrf/reject_non_public_test.exs +++ b/test/pleroma/web/activity_pub/mrf/reject_non_public_test.exs @@ -21,7 +21,7 @@ test "it's allowed when address is public" do "type" => "Create" } - assert {:ok, message} = RejectNonPublic.filter(message) + assert {:ok, _message} = RejectNonPublic.filter(message) end test "it's allowed when cc address contain public address" do @@ -34,7 +34,7 @@ test "it's allowed when cc address contain public address" do "type" => "Create" } - assert {:ok, message} = RejectNonPublic.filter(message) + assert {:ok, _message} = RejectNonPublic.filter(message) end end @@ -50,7 +50,7 @@ test "it's allowed when addrer of message in the follower addresses of user and } Pleroma.Config.put([:mrf_rejectnonpublic, :allow_followersonly], true) - assert {:ok, message} = RejectNonPublic.filter(message) + assert {:ok, _message} = RejectNonPublic.filter(message) end test "it's rejected when addrer of message in the follower addresses of user and it disabled in config" do @@ -80,7 +80,7 @@ test "it's allows when direct messages are allow" do } Pleroma.Config.put([:mrf_rejectnonpublic, :allow_direct], true) - assert {:ok, message} = RejectNonPublic.filter(message) + assert {:ok, _message} = RejectNonPublic.filter(message) end test "it's reject when direct messages aren't allow" do diff --git a/test/pleroma/web/activity_pub/mrf/tag_policy_test.exs b/test/pleroma/web/activity_pub/mrf/tag_policy_test.exs index 6ff71d640..4f289739f 100644 --- a/test/pleroma/web/activity_pub/mrf/tag_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/tag_policy_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.TagPolicyTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.ActivityPub.MRF.TagPolicy @@ -29,7 +29,7 @@ test "allows non-local follow requests" do actor = insert(:user, tags: ["mrf_tag:disable-remote-subscription"]) follower = insert(:user, tags: ["mrf_tag:disable-remote-subscription"], local: true) message = %{"object" => actor.ap_id, "type" => "Follow", "actor" => follower.ap_id} - assert {:ok, message} = TagPolicy.filter(message) + assert {:ok, _message} = TagPolicy.filter(message) end end diff --git a/test/pleroma/web/activity_pub/mrf_test.exs b/test/pleroma/web/activity_pub/mrf_test.exs index e8cdde2e1..44a9cf086 100644 --- a/test/pleroma/web/activity_pub/mrf_test.exs +++ b/test/pleroma/web/activity_pub/mrf_test.exs @@ -87,4 +87,20 @@ test "it works as expected with mock policy" do {:ok, ^expected} = MRF.describe() end end + + test "config_descriptions/0" do + descriptions = MRF.config_descriptions() + + good_mrf = Enum.find(descriptions, fn %{key: key} -> key == :good_mrf end) + + assert good_mrf == %{ + key: :good_mrf, + related_policy: "Fixtures.Modules.GoodMRF", + label: "Good MRF", + description: "Some description", + group: :pleroma, + tab: :mrf, + type: :group + } + end end diff --git a/test/pleroma/web/activity_pub/object_validators/accept_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/accept_validation_test.exs index d6111ba41..bafa2a672 100644 --- a/test/pleroma/web/activity_pub/object_validators/accept_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/accept_validation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator diff --git a/test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs index 4771c4698..9613dea9b 100644 --- a/test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Object alias Pleroma.Web.ActivityPub.Builder diff --git a/test/pleroma/web/activity_pub/object_validators/article_note_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/article_note_validator_test.exs index cc6dab872..1f992b397 100644 --- a/test/pleroma/web/activity_pub/object_validators/article_note_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/article_note_validator_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidatorTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator alias Pleroma.Web.ActivityPub.Utils diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs index 760388e80..45e1d8852 100644 --- a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator @@ -33,7 +33,8 @@ test "it turns mastodon attachments into our attachments" do "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", "type" => "Document", "name" => nil, - "mediaType" => "image/jpeg" + "mediaType" => "image/jpeg", + "blurhash" => "UD9jJz~VSbR#xT$~%KtQX9R,WAs9RjWBs:of" } {:ok, attachment} = @@ -50,6 +51,7 @@ test "it turns mastodon attachments into our attachments" do ] = attachment.url assert attachment.mediaType == "image/jpeg" + assert attachment.blurhash == "UD9jJz~VSbR#xT$~%KtQX9R,WAs9RjWBs:of" end test "it handles our own uploads" do diff --git a/test/pleroma/web/activity_pub/object_validators/block_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/block_validation_test.exs index c08d4b2e8..d133aeb1a 100644 --- a/test/pleroma/web/activity_pub/object_validators/block_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/block_validation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator diff --git a/test/pleroma/web/activity_pub/object_validators/delete_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/delete_validation_test.exs index 02683b899..57de83c8a 100644 --- a/test/pleroma/web/activity_pub/object_validators/delete_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/delete_validation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Object alias Pleroma.Web.ActivityPub.Builder diff --git a/test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs b/test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs index 582e6d785..342cfeef8 100644 --- a/test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/emoji_react_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator diff --git a/test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs index 6e1378be2..0f77ac8df 100644 --- a/test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.FollowValidationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator diff --git a/test/pleroma/web/activity_pub/object_validators/like_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/like_validation_test.exs index 2c033b7e2..4cda3742d 100644 --- a/test/pleroma/web/activity_pub/object_validators/like_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/like_validation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator diff --git a/test/pleroma/web/activity_pub/object_validators/reject_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/reject_validation_test.exs index 370bb6e5c..69f5e8ac4 100644 --- a/test/pleroma/web/activity_pub/object_validators/reject_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/reject_validation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.RejectValidationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator diff --git a/test/pleroma/web/activity_pub/object_validators/undo_handling_test.exs b/test/pleroma/web/activity_pub/object_validators/undo_handling_test.exs index 75bbcc4b6..dc85d1ac3 100644 --- a/test/pleroma/web/activity_pub/object_validators/undo_handling_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/undo_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator diff --git a/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs index 5e80cf731..2c4a50bfd 100644 --- a/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator diff --git a/test/pleroma/web/activity_pub/pipeline_test.exs b/test/pleroma/web/activity_pub/pipeline_test.exs index 210a06563..d568d825b 100644 --- a/test/pleroma/web/activity_pub/pipeline_test.exs +++ b/test/pleroma/web/activity_pub/pipeline_test.exs @@ -3,14 +3,35 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.PipelineTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true - import Mock + import Mox import Pleroma.Factory + alias Pleroma.ConfigMock + alias Pleroma.Web.ActivityPub.ActivityPubMock + alias Pleroma.Web.ActivityPub.MRFMock + alias Pleroma.Web.ActivityPub.ObjectValidatorMock + alias Pleroma.Web.ActivityPub.SideEffectsMock + alias Pleroma.Web.FederatorMock + + setup :verify_on_exit! + describe "common_pipeline/2" do setup do - clear_config([:instance, :federating], true) + ObjectValidatorMock + |> expect(:validate, fn o, m -> {:ok, o, m} end) + + MRFMock + |> expect(:pipeline_filter, fn o, m -> {:ok, o, m} end) + + ActivityPubMock + |> expect(:persist, fn o, m -> {:ok, o, m} end) + + SideEffectsMock + |> expect(:handle, fn o, m -> {:ok, o, m} end) + |> expect(:handle_after_transaction, fn m -> m end) + :ok end @@ -21,159 +42,53 @@ test "when given an `object_data` in meta, Federation will receive a the origina activity_with_object = %{activity | data: Map.put(activity.data, "object", object)} - with_mocks([ - {Pleroma.Web.ActivityPub.ObjectValidator, [], [validate: fn o, m -> {:ok, o, m} end]}, - { - Pleroma.Web.ActivityPub.MRF, - [], - [pipeline_filter: fn o, m -> {:ok, o, m} end] - }, - { - Pleroma.Web.ActivityPub.ActivityPub, - [], - [persist: fn o, m -> {:ok, o, m} end] - }, - { - Pleroma.Web.ActivityPub.SideEffects, - [], - [ - handle: fn o, m -> {:ok, o, m} end, - handle_after_transaction: fn m -> m end - ] - }, - { - Pleroma.Web.Federator, - [], - [publish: fn _o -> :ok end] - } - ]) do - assert {:ok, ^activity, ^meta} = - Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + FederatorMock + |> expect(:publish, fn ^activity_with_object -> :ok end) - assert_called(Pleroma.Web.ActivityPub.ObjectValidator.validate(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.MRF.pipeline_filter(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.ActivityPub.persist(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta)) - refute called(Pleroma.Web.Federator.publish(activity)) - assert_called(Pleroma.Web.Federator.publish(activity_with_object)) - end + ConfigMock + |> expect(:get, fn [:instance, :federating] -> true end) + + assert {:ok, ^activity, ^meta} = + Pleroma.Web.ActivityPub.Pipeline.common_pipeline( + activity, + meta + ) end test "it goes through validation, filtering, persisting, side effects and federation for local activities" do activity = insert(:note_activity) meta = [local: true] - with_mocks([ - {Pleroma.Web.ActivityPub.ObjectValidator, [], [validate: fn o, m -> {:ok, o, m} end]}, - { - Pleroma.Web.ActivityPub.MRF, - [], - [pipeline_filter: fn o, m -> {:ok, o, m} end] - }, - { - Pleroma.Web.ActivityPub.ActivityPub, - [], - [persist: fn o, m -> {:ok, o, m} end] - }, - { - Pleroma.Web.ActivityPub.SideEffects, - [], - [ - handle: fn o, m -> {:ok, o, m} end, - handle_after_transaction: fn m -> m end - ] - }, - { - Pleroma.Web.Federator, - [], - [publish: fn _o -> :ok end] - } - ]) do - assert {:ok, ^activity, ^meta} = - Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + FederatorMock + |> expect(:publish, fn ^activity -> :ok end) - assert_called(Pleroma.Web.ActivityPub.ObjectValidator.validate(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.MRF.pipeline_filter(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.ActivityPub.persist(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta)) - assert_called(Pleroma.Web.Federator.publish(activity)) - end + ConfigMock + |> expect(:get, fn [:instance, :federating] -> true end) + + assert {:ok, ^activity, ^meta} = + Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) end test "it goes through validation, filtering, persisting, side effects without federation for remote activities" do activity = insert(:note_activity) meta = [local: false] - with_mocks([ - {Pleroma.Web.ActivityPub.ObjectValidator, [], [validate: fn o, m -> {:ok, o, m} end]}, - { - Pleroma.Web.ActivityPub.MRF, - [], - [pipeline_filter: fn o, m -> {:ok, o, m} end] - }, - { - Pleroma.Web.ActivityPub.ActivityPub, - [], - [persist: fn o, m -> {:ok, o, m} end] - }, - { - Pleroma.Web.ActivityPub.SideEffects, - [], - [handle: fn o, m -> {:ok, o, m} end, handle_after_transaction: fn m -> m end] - }, - { - Pleroma.Web.Federator, - [], - [] - } - ]) do - assert {:ok, ^activity, ^meta} = - Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + ConfigMock + |> expect(:get, fn [:instance, :federating] -> true end) - assert_called(Pleroma.Web.ActivityPub.ObjectValidator.validate(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.MRF.pipeline_filter(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.ActivityPub.persist(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta)) - end + assert {:ok, ^activity, ^meta} = + Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) end test "it goes through validation, filtering, persisting, side effects without federation for local activities if federation is deactivated" do - clear_config([:instance, :federating], false) - activity = insert(:note_activity) meta = [local: true] - with_mocks([ - {Pleroma.Web.ActivityPub.ObjectValidator, [], [validate: fn o, m -> {:ok, o, m} end]}, - { - Pleroma.Web.ActivityPub.MRF, - [], - [pipeline_filter: fn o, m -> {:ok, o, m} end] - }, - { - Pleroma.Web.ActivityPub.ActivityPub, - [], - [persist: fn o, m -> {:ok, o, m} end] - }, - { - Pleroma.Web.ActivityPub.SideEffects, - [], - [handle: fn o, m -> {:ok, o, m} end, handle_after_transaction: fn m -> m end] - }, - { - Pleroma.Web.Federator, - [], - [] - } - ]) do - assert {:ok, ^activity, ^meta} = - Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + ConfigMock + |> expect(:get, fn [:instance, :federating] -> false end) - assert_called(Pleroma.Web.ActivityPub.ObjectValidator.validate(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.MRF.pipeline_filter(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.ActivityPub.persist(activity, meta)) - assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta)) - end + assert {:ok, ^activity, ^meta} = + Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) end end end diff --git a/test/pleroma/web/activity_pub/publisher_test.exs b/test/pleroma/web/activity_pub/publisher_test.exs index b9388b966..3503d25b2 100644 --- a/test/pleroma/web/activity_pub/publisher_test.exs +++ b/test/pleroma/web/activity_pub/publisher_test.exs @@ -281,8 +281,7 @@ test "publish to url with with different ports" do actor = insert(:user, follower_address: follower.ap_id) user = insert(:user) - {:ok, _follower_one} = Pleroma.User.follow(follower, actor) - actor = refresh_record(actor) + {:ok, follower, actor} = Pleroma.User.follow(follower, actor) note_activity = insert(:note_activity, diff --git a/test/pleroma/web/activity_pub/relay_test.exs b/test/pleroma/web/activity_pub/relay_test.exs index 3284980f7..a7cd732bb 100644 --- a/test/pleroma/web/activity_pub/relay_test.exs +++ b/test/pleroma/web/activity_pub/relay_test.exs @@ -84,7 +84,7 @@ test "force unfollow when target service is dead" do ) Pleroma.Repo.delete(user) - Cachex.clear(:user_cache) + User.invalidate_cache(user) assert {:ok, %Activity{} = activity} = Relay.unfollow(user_ap_id, %{force: true}) diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index 9efbaad04..297fc0b84 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -108,7 +108,7 @@ test "it blocks but does not unfollow if the relevant setting is set", %{ describe "update users" do setup do - user = insert(:user) + user = insert(:user, local: false) {:ok, update_data, []} = Builder.update(user, %{"id" => user.ap_id, "name" => "new name!"}) {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) diff --git a/test/pleroma/web/activity_pub/transmogrifier/accept_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/accept_handling_test.exs index c6ff96f08..d356fcc72 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/accept_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/accept_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.AcceptHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier @@ -15,14 +15,14 @@ test "it works for incoming accepts which were pre-accepted" do follower = insert(:user) followed = insert(:user) - {:ok, follower} = User.follow(follower, followed) + {:ok, follower, followed} = User.follow(follower, followed) assert User.following?(follower, followed) == true {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", followed.ap_id) object = @@ -52,7 +52,7 @@ test "it works for incoming accepts which are referenced by IRI only" do accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", followed.ap_id) |> Map.put("object", follow_activity.data["id"]) @@ -76,7 +76,7 @@ test "it fails for incoming accepts which cannot be correlated" do accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", followed.ap_id) accept_data = diff --git a/test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs index e895636b5..c06bbc5e9 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs @@ -36,7 +36,7 @@ test "it works for incoming honk announces" do end test "it works for incoming announces with actor being inlined (kroeg)" do - data = File.read!("test/fixtures/kroeg-announce-with-inline-actor.json") |> Poison.decode!() + data = File.read!("test/fixtures/kroeg-announce-with-inline-actor.json") |> Jason.decode!() _user = insert(:user, local: false, ap_id: data["actor"]["id"]) other_user = insert(:user) @@ -55,12 +55,16 @@ test "it works for incoming announces with actor being inlined (kroeg)" do test "it works for incoming announces, fetching the announced object" do data = File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", "http://mastodon.example.org/users/admin/statuses/99541947525187367") Tesla.Mock.mock(fn %{method: :get} -> - %Tesla.Env{status: 200, body: File.read!("test/fixtures/mastodon-note-object.json")} + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/mastodon-note-object.json"), + headers: HttpRequestMock.activitypub_object_headers() + } end) _user = insert(:user, local: false, ap_id: data["actor"]) @@ -86,7 +90,7 @@ test "it works for incoming announces with an existing activity" do data = File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) _user = insert(:user, local: false, ap_id: data["actor"]) @@ -109,7 +113,7 @@ test "it works for incoming announces with an existing activity" do test "it works for incoming announces with an inlined activity" do data = File.read!("test/fixtures/mastodon-announce-private.json") - |> Poison.decode!() + |> Jason.decode!() _user = insert(:user, @@ -140,11 +144,11 @@ test "it rejects incoming announces with an inlined activity from another origin data = File.read!("test/fixtures/bogus-mastodon-announce.json") - |> Poison.decode!() + |> Jason.decode!() _user = insert(:user, local: false, ap_id: data["actor"]) - assert {:error, e} = Transmogrifier.handle_incoming(data) + assert {:error, _e} = Transmogrifier.handle_incoming(data) end test "it does not clobber the addressing on announce activities" do @@ -153,7 +157,7 @@ test "it does not clobber the addressing on announce activities" do data = File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", Object.normalize(activity).data["id"]) |> Map.put("to", ["http://mastodon.example.org/users/admin/followers"]) |> Map.put("cc", []) diff --git a/test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs index 0f6605c3f..a1c2ba28a 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/answer_handling_test.exs @@ -27,10 +27,11 @@ test "incoming, rewrites Note to Answer and increments vote counters" do }) object = Object.normalize(activity) + assert object.data["repliesCount"] == nil data = File.read!("test/fixtures/mastodon-vote.json") - |> Poison.decode!() + |> Jason.decode!() |> Kernel.put_in(["to"], user.ap_id) |> Kernel.put_in(["object", "inReplyTo"], object.data["id"]) |> Kernel.put_in(["object", "to"], user.ap_id) @@ -41,7 +42,7 @@ test "incoming, rewrites Note to Answer and increments vote counters" do assert answer_object.data["inReplyTo"] == object.data["id"] new_object = Object.get_by_ap_id(object.data["id"]) - assert new_object.data["replies_count"] == object.data["replies_count"] + assert new_object.data["repliesCount"] == nil assert Enum.any?( new_object.data["oneOf"], @@ -65,7 +66,7 @@ test "outgoing, rewrites Answer to Note" do # TODO: Replace with CommonAPI vote creation when implemented data = File.read!("test/fixtures/mastodon-vote.json") - |> Poison.decode!() + |> Jason.decode!() |> Kernel.put_in(["to"], user.ap_id) |> Kernel.put_in(["object", "inReplyTo"], poll_object.data["id"]) |> Kernel.put_in(["object", "to"], user.ap_id) diff --git a/test/pleroma/web/activity_pub/transmogrifier/article_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/article_handling_test.exs index 9b12a470a..b0ae804c5 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/article_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/article_handling_test.exs @@ -13,7 +13,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ArticleHandlingTest do test "Pterotype (Wordpress Plugin) Article" do Tesla.Mock.mock(fn %{url: "https://wedistribute.org/wp-json/pterotype/v1/actor/-blog"} -> - %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/wedistribute-user.json")} + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/wedistribute-user.json"), + headers: HttpRequestMock.activitypub_object_headers() + } end) data = @@ -36,13 +40,15 @@ test "Plume Article" do %{url: "https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june-2018/"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json") + body: File.read!("test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json"), + headers: HttpRequestMock.activitypub_object_headers() } %{url: "https://baptiste.gelez.xyz/@/BaptisteGelez"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/baptiste.gelex.xyz-user.json") + body: File.read!("test/fixtures/tesla_mock/baptiste.gelex.xyz-user.json"), + headers: HttpRequestMock.activitypub_object_headers() } end) @@ -61,7 +67,8 @@ test "Prismo Article" do Tesla.Mock.mock(fn %{url: "https://prismo.news/@mxb"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/https___prismo.news__mxb.json") + body: File.read!("test/fixtures/tesla_mock/https___prismo.news__mxb.json"), + headers: HttpRequestMock.activitypub_object_headers() } end) diff --git a/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs index 0636d00c5..7a2ac5d4d 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs @@ -48,11 +48,12 @@ test "Funkwhale Audio object" do %{url: "https://channels.tests.funkwhale.audio/federation/actors/compositions"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/funkwhale_channel.json") + body: File.read!("test/fixtures/tesla_mock/funkwhale_channel.json"), + headers: HttpRequestMock.activitypub_object_headers() } end) - data = File.read!("test/fixtures/tesla_mock/funkwhale_create_audio.json") |> Poison.decode!() + data = File.read!("test/fixtures/tesla_mock/funkwhale_create_audio.json") |> Jason.decode!() {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) @@ -69,6 +70,7 @@ test "Funkwhale Audio object" do "mediaType" => "audio/ogg", "type" => "Link", "name" => nil, + "blurhash" => nil, "url" => [ %{ "href" => diff --git a/test/pleroma/web/activity_pub/transmogrifier/block_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/block_handling_test.exs index 71f1a0ed5..6adad88f5 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/block_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/block_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.BlockHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.User @@ -16,7 +16,7 @@ test "it works for incoming blocks" do data = File.read!("test/fixtures/mastodon-block-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) blocker = insert(:user, ap_id: data["actor"]) @@ -36,12 +36,12 @@ test "incoming blocks successfully tear down any follow relationship" do data = File.read!("test/fixtures/mastodon-block-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", blocked.ap_id) |> Map.put("actor", blocker.ap_id) - {:ok, blocker} = User.follow(blocker, blocked) - {:ok, blocked} = User.follow(blocked, blocker) + {:ok, blocker, blocked} = User.follow(blocker, blocked) + {:ok, blocked, blocker} = User.follow(blocked, blocker) assert User.following?(blocker, blocked) assert User.following?(blocked, blocker) diff --git a/test/pleroma/web/activity_pub/transmogrifier/chat_message_test.exs b/test/pleroma/web/activity_pub/transmogrifier/chat_message_test.exs index 31274c067..2adaa1ade 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/chat_message_test.exs @@ -53,7 +53,7 @@ test "handles chonks with attachment" do test "it rejects messages that don't contain content" do data = File.read!("test/fixtures/create-chat-message.json") - |> Poison.decode!() + |> Jason.decode!() object = data["object"] @@ -79,7 +79,7 @@ test "it rejects messages that don't contain content" do test "it rejects messages that don't concern local users" do data = File.read!("test/fixtures/create-chat-message.json") - |> Poison.decode!() + |> Jason.decode!() _author = insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) @@ -97,7 +97,7 @@ test "it rejects messages that don't concern local users" do test "it rejects messages where the `to` field of activity and object don't match" do data = File.read!("test/fixtures/create-chat-message.json") - |> Poison.decode!() + |> Jason.decode!() author = insert(:user, ap_id: data["actor"]) _recipient = insert(:user, ap_id: List.first(data["to"])) @@ -115,7 +115,7 @@ test "it fetches the actor if they aren't in our system" do data = File.read!("test/fixtures/create-chat-message.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", "http://mastodon.example.org/users/admin") |> put_in(["object", "actor"], "http://mastodon.example.org/users/admin") @@ -127,7 +127,7 @@ test "it fetches the actor if they aren't in our system" do test "it doesn't work for deactivated users" do data = File.read!("test/fixtures/create-chat-message.json") - |> Poison.decode!() + |> Jason.decode!() _author = insert(:user, @@ -145,7 +145,7 @@ test "it doesn't work for deactivated users" do test "it inserts it and creates a chat" do data = File.read!("test/fixtures/create-chat-message.json") - |> Poison.decode!() + |> Jason.decode!() author = insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) diff --git a/test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs index c9a53918c..1f9e73ff8 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/delete_handling_test.exs @@ -25,7 +25,7 @@ test "it works for incoming deletes" do data = File.read!("test/fixtures/mastodon-delete.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", deleting_user.ap_id) |> put_in(["object", "id"], activity.data["object"]) @@ -51,13 +51,14 @@ test "it works for incoming when the object has been pruned" do Object.normalize(activity.data["object"]) |> Repo.delete() + # TODO: mock cachex Cachex.del(:object_cache, "object:#{object.data["id"]}") deleting_user = insert(:user) data = File.read!("test/fixtures/mastodon-delete.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", deleting_user.ap_id) |> put_in(["object", "id"], activity.data["object"]) @@ -78,7 +79,7 @@ test "it fails for incoming deletes with spoofed origin" do data = File.read!("test/fixtures/mastodon-delete.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", ap_id) |> put_in(["object", "id"], activity.data["object"]) @@ -91,7 +92,7 @@ test "it works for incoming user deletes" do data = File.read!("test/fixtures/mastodon-delete-user.json") - |> Poison.decode!() + |> Jason.decode!() {:ok, _} = Transmogrifier.handle_incoming(data) ObanHelpers.perform_all() @@ -104,7 +105,7 @@ test "it fails for incoming user deletes with spoofed origin" do data = File.read!("test/fixtures/mastodon-delete-user.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", ap_id) assert match?({:error, _}, Transmogrifier.handle_incoming(data)) diff --git a/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs index 0fb056b50..1ebf6b1e8 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiReactHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.Object @@ -19,7 +19,7 @@ test "it works for incoming emoji reactions" do data = File.read!("test/fixtures/emoji-reaction.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) |> Map.put("actor", other_user.ap_id) @@ -44,7 +44,7 @@ test "it reject invalid emoji reactions" do data = File.read!("test/fixtures/emoji-reaction-too-long.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) |> Map.put("actor", other_user.ap_id) @@ -52,7 +52,7 @@ test "it reject invalid emoji reactions" do data = File.read!("test/fixtures/emoji-reaction-no-emoji.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) |> Map.put("actor", other_user.ap_id) diff --git a/test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs index 7f1ef2cbd..d7c55cfbe 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs @@ -13,13 +13,15 @@ test "Mobilizon Event object" do %{url: "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/mobilizon.org-event.json") + body: File.read!("test/fixtures/tesla_mock/mobilizon.org-event.json"), + headers: HttpRequestMock.activitypub_object_headers() } %{url: "https://mobilizon.org/@tcit"} -> %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/mobilizon.org-user.json") + body: File.read!("test/fixtures/tesla_mock/mobilizon.org-user.json"), + headers: HttpRequestMock.activitypub_object_headers() } end) diff --git a/test/pleroma/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/follow_handling_test.exs index 4ef8210ad..985c26def 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -28,7 +28,7 @@ test "it works for osada follow request" do data = File.read!("test/fixtures/osada-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data) @@ -47,7 +47,7 @@ test "it works for incoming follow requests" do data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data) @@ -69,7 +69,7 @@ test "with locked accounts, it does create a Follow, but not an Accept" do data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) @@ -100,7 +100,7 @@ test "it works for follow requests when you are already followed, creating a new data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(data) @@ -116,7 +116,7 @@ test "it works for follow requests when you are already followed, creating a new data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("id", String.replace(data["id"], "2", "3")) |> Map.put("object", user.ap_id) @@ -142,7 +142,7 @@ test "it rejects incoming follow requests from blocked users when deny_follow_bl data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) {:ok, %Activity{data: %{"id" => id}}} = Transmogrifier.handle_incoming(data) @@ -157,7 +157,7 @@ test "it rejects incoming follow requests if the following errors for some reaso data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) with_mock Pleroma.User, [:passthrough], follow: fn _, _, _ -> {:error, :testing} end do @@ -174,7 +174,7 @@ test "it works for incoming follow requests from hubzilla" do data = File.read!("test/fixtures/hubzilla-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) |> Utils.normalize_params() @@ -192,7 +192,7 @@ test "it works for incoming follows to locked account" do data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) diff --git a/test/pleroma/web/activity_pub/transmogrifier/like_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/like_handling_test.exs index 53fe1d550..35211b8f2 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/like_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/like_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.LikeHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.Web.ActivityPub.Transmogrifier @@ -18,7 +18,7 @@ test "it works for incoming likes" do data = File.read!("test/fixtures/mastodon-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) _actor = insert(:user, ap_id: data["actor"], local: false) @@ -40,7 +40,7 @@ test "it works for incoming misskey likes, turning them into EmojiReacts" do data = File.read!("test/fixtures/misskey-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) _actor = insert(:user, ap_id: data["actor"], local: false) @@ -61,7 +61,7 @@ test "it works for incoming misskey likes that contain unicode emojis, turning t data = File.read!("test/fixtures/misskey-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) |> Map.put("_misskey_reaction", "โญ") diff --git a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs new file mode 100644 index 000000000..b4a006aec --- /dev/null +++ b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs @@ -0,0 +1,749 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Mock + import Pleroma.Factory + import ExUnit.CaptureLog + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + setup do: clear_config([:instance, :max_remote_account_fields]) + + describe "handle_incoming" do + test "it works for incoming notices with tag not being an array (kroeg)" do + data = File.read!("test/fixtures/kroeg-array-less-emoji.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) + + assert object.data["emoji"] == %{ + "icon_e_smile" => "https://puckipedia.com/forum/images/smilies/icon_e_smile.png" + } + + data = File.read!("test/fixtures/kroeg-array-less-hashtag.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) + + assert "test" in object.data["tag"] + end + + test "it cleans up incoming notices which are not really DMs" do + user = insert(:user) + other_user = insert(:user) + + to = [user.ap_id, other_user.ap_id] + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + |> Map.put("to", to) + |> Map.put("cc", []) + + object = + data["object"] + |> Map.put("to", to) + |> Map.put("cc", []) + + data = Map.put(data, "object", object) + + {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data) + + assert data["to"] == [] + assert data["cc"] == to + + object_data = Object.normalize(activity).data + + assert object_data["to"] == [] + assert object_data["cc"] == to + end + + test "it ignores an incoming notice if we already have it" do + activity = insert(:note_activity) + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + |> Map.put("object", Object.normalize(activity).data) + + {:ok, returned_activity} = Transmogrifier.handle_incoming(data) + + assert activity == returned_activity + end + + @tag capture_log: true + test "it fetches reply-to activities if we don't have them" do + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + + object = + data["object"] + |> Map.put("inReplyTo", "https://mstdn.io/users/mayuutann/statuses/99568293732299394") + + data = Map.put(data, "object", object) + {:ok, returned_activity} = Transmogrifier.handle_incoming(data) + returned_object = Object.normalize(returned_activity, false) + + assert %Activity{} = + Activity.get_create_by_object_ap_id( + "https://mstdn.io/users/mayuutann/statuses/99568293732299394" + ) + + assert returned_object.data["inReplyTo"] == + "https://mstdn.io/users/mayuutann/statuses/99568293732299394" + end + + test "it does not fetch reply-to activities beyond max replies depth limit" do + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + + object = + data["object"] + |> Map.put("inReplyTo", "https://shitposter.club/notice/2827873") + + data = Map.put(data, "object", object) + + with_mock Pleroma.Web.Federator, + allowed_thread_distance?: fn _ -> false end do + {:ok, returned_activity} = Transmogrifier.handle_incoming(data) + + returned_object = Object.normalize(returned_activity, false) + + refute Activity.get_create_by_object_ap_id( + "tag:shitposter.club,2017-05-05:noticeId=2827873:objectType=comment" + ) + + assert returned_object.data["inReplyTo"] == "https://shitposter.club/notice/2827873" + end + end + + test "it does not crash if the object in inReplyTo can't be fetched" do + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + + object = + data["object"] + |> Map.put("inReplyTo", "https://404.site/whatever") + + data = + data + |> Map.put("object", object) + + assert capture_log(fn -> + {:ok, _returned_activity} = Transmogrifier.handle_incoming(data) + end) =~ "[warn] Couldn't fetch \"https://404.site/whatever\", error: nil" + end + + test "it does not work for deactivated users" do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + + insert(:user, ap_id: data["actor"], deactivated: true) + + assert {:error, _} = Transmogrifier.handle_incoming(data) + end + + test "it works for incoming notices" do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["id"] == + "http://mastodon.example.org/users/admin/statuses/99512778738411822/activity" + + assert data["context"] == + "tag:mastodon.example.org,2018-02-12:objectId=20:objectType=Conversation" + + assert data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] + + assert data["cc"] == [ + "http://mastodon.example.org/users/admin/followers", + "http://localtesting.pleroma.lol/users/lain" + ] + + assert data["actor"] == "http://mastodon.example.org/users/admin" + + object_data = Object.normalize(data["object"]).data + + assert object_data["id"] == + "http://mastodon.example.org/users/admin/statuses/99512778738411822" + + assert object_data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] + + assert object_data["cc"] == [ + "http://mastodon.example.org/users/admin/followers", + "http://localtesting.pleroma.lol/users/lain" + ] + + assert object_data["actor"] == "http://mastodon.example.org/users/admin" + assert object_data["attributedTo"] == "http://mastodon.example.org/users/admin" + + assert object_data["context"] == + "tag:mastodon.example.org,2018-02-12:objectId=20:objectType=Conversation" + + assert object_data["sensitive"] == true + + user = User.get_cached_by_ap_id(object_data["actor"]) + + assert user.note_count == 1 + end + + test "it works for incoming notices without the sensitive property but an nsfw hashtag" do + data = File.read!("test/fixtures/mastodon-post-activity-nsfw.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + object_data = Object.normalize(data["object"], false).data + + assert object_data["sensitive"] == true + end + + test "it works for incoming notices with hashtags" do + data = File.read!("test/fixtures/mastodon-post-activity-hashtag.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) + + assert Enum.at(object.data["tag"], 2) == "moo" + end + + test "it works for incoming notices with contentMap" do + data = File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) + + assert object.data["content"] == + "

    @lain

    " + end + + test "it works for incoming notices with to/cc not being an array (kroeg)" do + data = File.read!("test/fixtures/kroeg-post-activity.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) + + assert object.data["content"] == + "

    henlo from my Psion netBook

    message sent from my Psion netBook

    " + end + + test "it ensures that as:Public activities make it to their followers collection" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + |> Map.put("actor", user.ap_id) + |> Map.put("to", ["https://www.w3.org/ns/activitystreams#Public"]) + |> Map.put("cc", []) + + object = + data["object"] + |> Map.put("attributedTo", user.ap_id) + |> Map.put("to", ["https://www.w3.org/ns/activitystreams#Public"]) + |> Map.put("cc", []) + |> Map.put("id", user.ap_id <> "/activities/12345678") + + data = Map.put(data, "object", object) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["cc"] == [User.ap_followers(user)] + end + + test "it ensures that address fields become lists" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + |> Map.put("actor", user.ap_id) + |> Map.put("to", nil) + |> Map.put("cc", nil) + + object = + data["object"] + |> Map.put("attributedTo", user.ap_id) + |> Map.put("to", nil) + |> Map.put("cc", nil) + |> Map.put("id", user.ap_id <> "/activities/12345678") + + data = Map.put(data, "object", object) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert !is_nil(data["to"]) + assert !is_nil(data["cc"]) + end + + test "it strips internal likes" do + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + + likes = %{ + "first" => + "http://mastodon.example.org/objects/dbdbc507-52c8-490d-9b7c-1e1d52e5c132/likes?page=1", + "id" => "http://mastodon.example.org/objects/dbdbc507-52c8-490d-9b7c-1e1d52e5c132/likes", + "totalItems" => 3, + "type" => "OrderedCollection" + } + + object = Map.put(data["object"], "likes", likes) + data = Map.put(data, "object", object) + + {:ok, %Activity{object: object}} = Transmogrifier.handle_incoming(data) + + refute Map.has_key?(object.data, "likes") + end + + test "it strips internal reactions" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) + {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "๐Ÿ“ข") + + %{object: object} = Activity.get_by_id_with_object(activity.id) + assert Map.has_key?(object.data, "reactions") + assert Map.has_key?(object.data, "reaction_count") + + object_data = Transmogrifier.strip_internal_fields(object.data) + refute Map.has_key?(object_data, "reactions") + refute Map.has_key?(object_data, "reaction_count") + end + + test "it correctly processes messages with non-array to field" do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => "https://www.w3.org/ns/activitystreams#Public", + "type" => "Create", + "object" => %{ + "content" => "blah blah blah", + "type" => "Note", + "attributedTo" => user.ap_id, + "inReplyTo" => nil + }, + "actor" => user.ap_id + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + + assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["to"] + end + + test "it correctly processes messages with non-array cc field" do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => user.follower_address, + "cc" => "https://www.w3.org/ns/activitystreams#Public", + "type" => "Create", + "object" => %{ + "content" => "blah blah blah", + "type" => "Note", + "attributedTo" => user.ap_id, + "inReplyTo" => nil + }, + "actor" => user.ap_id + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + + assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"] + assert [user.follower_address] == activity.data["to"] + end + + test "it correctly processes messages with weirdness in address fields" do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [nil, user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public", ["ยฟ"]], + "type" => "Create", + "object" => %{ + "content" => "โ€ฆ", + "type" => "Note", + "attributedTo" => user.ap_id, + "inReplyTo" => nil + }, + "actor" => user.ap_id + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + + assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"] + assert [user.follower_address] == activity.data["to"] + end + end + + describe "`handle_incoming/2`, Mastodon format `replies` handling" do + setup do: clear_config([:activitypub, :note_replies_output_limit], 5) + setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) + + setup do + data = + "test/fixtures/mastodon-post-activity.json" + |> File.read!() + |> Jason.decode!() + + items = get_in(data, ["object", "replies", "first", "items"]) + assert length(items) > 0 + + %{data: data, items: items} + end + + test "schedules background fetching of `replies` items if max thread depth limit allows", %{ + data: data, + items: items + } do + Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 10) + + {:ok, _activity} = Transmogrifier.handle_incoming(data) + + for id <- items do + job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1} + assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args) + end + end + + test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows", + %{data: data} do + Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0) + + {:ok, _activity} = Transmogrifier.handle_incoming(data) + + assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == [] + end + end + + describe "`handle_incoming/2`, Pleroma format `replies` handling" do + setup do: clear_config([:activitypub, :note_replies_output_limit], 5) + setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) + + setup do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "post1"}) + + {:ok, reply1} = + CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id}) + + {:ok, reply2} = + CommonAPI.post(user, %{status: "reply2", in_reply_to_status_id: activity.id}) + + replies_uris = Enum.map([reply1, reply2], fn a -> a.object.data["id"] end) + + {:ok, federation_output} = Transmogrifier.prepare_outgoing(activity.data) + + Repo.delete(activity.object) + Repo.delete(activity) + + %{federation_output: federation_output, replies_uris: replies_uris} + end + + test "schedules background fetching of `replies` items if max thread depth limit allows", %{ + federation_output: federation_output, + replies_uris: replies_uris + } do + Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 1) + + {:ok, _activity} = Transmogrifier.handle_incoming(federation_output) + + for id <- replies_uris do + job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1} + assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args) + end + end + + test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows", + %{federation_output: federation_output} do + Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0) + + {:ok, _activity} = Transmogrifier.handle_incoming(federation_output) + + assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == [] + end + end + + describe "reserialization" do + test "successfully reserializes a message with inReplyTo == nil" do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Create", + "object" => %{ + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Note", + "content" => "Hi", + "inReplyTo" => nil, + "attributedTo" => user.ap_id + }, + "actor" => user.ap_id + } + + {:ok, activity} = Transmogrifier.handle_incoming(message) + + {:ok, _} = Transmogrifier.prepare_outgoing(activity.data) + end + + test "successfully reserializes a message with AS2 objects in IR" do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Create", + "object" => %{ + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Note", + "content" => "Hi", + "inReplyTo" => nil, + "attributedTo" => user.ap_id, + "tag" => [ + %{"name" => "#2hu", "href" => "http://example.com/2hu", "type" => "Hashtag"}, + %{"name" => "Bob", "href" => "http://example.com/bob", "type" => "Mention"} + ] + }, + "actor" => user.ap_id + } + + {:ok, activity} = Transmogrifier.handle_incoming(message) + + {:ok, _} = Transmogrifier.prepare_outgoing(activity.data) + end + end + + describe "fix_in_reply_to/2" do + setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) + + setup do + data = Jason.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) + [data: data] + end + + test "returns not modified object when hasn't containts inReplyTo field", %{data: data} do + assert Transmogrifier.fix_in_reply_to(data) == data + end + + test "returns object with inReplyTo when denied incoming reply", %{data: data} do + Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0) + + object_with_reply = + Map.put(data["object"], "inReplyTo", "https://shitposter.club/notice/2827873") + + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + assert modified_object["inReplyTo"] == "https://shitposter.club/notice/2827873" + + object_with_reply = + Map.put(data["object"], "inReplyTo", %{"id" => "https://shitposter.club/notice/2827873"}) + + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + assert modified_object["inReplyTo"] == %{"id" => "https://shitposter.club/notice/2827873"} + + object_with_reply = + Map.put(data["object"], "inReplyTo", ["https://shitposter.club/notice/2827873"]) + + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + assert modified_object["inReplyTo"] == ["https://shitposter.club/notice/2827873"] + + object_with_reply = Map.put(data["object"], "inReplyTo", []) + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + assert modified_object["inReplyTo"] == [] + end + + @tag capture_log: true + test "returns modified object when allowed incoming reply", %{data: data} do + object_with_reply = + Map.put( + data["object"], + "inReplyTo", + "https://mstdn.io/users/mayuutann/statuses/99568293732299394" + ) + + Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 5) + modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) + + assert modified_object["inReplyTo"] == + "https://mstdn.io/users/mayuutann/statuses/99568293732299394" + + assert modified_object["context"] == + "tag:shitposter.club,2018-02-22:objectType=thread:nonce=e5a7c72d60a9c0e4" + end + end + + describe "fix_attachments/1" do + test "returns not modified object" do + data = Jason.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) + assert Transmogrifier.fix_attachments(data) == data + end + + test "returns modified object when attachment is map" do + assert Transmogrifier.fix_attachments(%{ + "attachment" => %{ + "mediaType" => "video/mp4", + "url" => "https://peertube.moe/stat-480.mp4" + } + }) == %{ + "attachment" => [ + %{ + "mediaType" => "video/mp4", + "type" => "Document", + "url" => [ + %{ + "href" => "https://peertube.moe/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } + ] + } + ] + } + end + + test "returns modified object when attachment is list" do + assert Transmogrifier.fix_attachments(%{ + "attachment" => [ + %{"mediaType" => "video/mp4", "url" => "https://pe.er/stat-480.mp4"}, + %{"mimeType" => "video/mp4", "href" => "https://pe.er/stat-480.mp4"} + ] + }) == %{ + "attachment" => [ + %{ + "mediaType" => "video/mp4", + "type" => "Document", + "url" => [ + %{ + "href" => "https://pe.er/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } + ] + }, + %{ + "mediaType" => "video/mp4", + "type" => "Document", + "url" => [ + %{ + "href" => "https://pe.er/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } + ] + } + ] + } + end + end + + describe "fix_emoji/1" do + test "returns not modified object when object not contains tags" do + data = Jason.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) + assert Transmogrifier.fix_emoji(data) == data + end + + test "returns object with emoji when object contains list tags" do + assert Transmogrifier.fix_emoji(%{ + "tag" => [ + %{"type" => "Emoji", "name" => ":bib:", "icon" => %{"url" => "/test"}}, + %{"type" => "Hashtag"} + ] + }) == %{ + "emoji" => %{"bib" => "/test"}, + "tag" => [ + %{"icon" => %{"url" => "/test"}, "name" => ":bib:", "type" => "Emoji"}, + %{"type" => "Hashtag"} + ] + } + end + + test "returns object with emoji when object contains map tag" do + assert Transmogrifier.fix_emoji(%{ + "tag" => %{"type" => "Emoji", "name" => ":bib:", "icon" => %{"url" => "/test"}} + }) == %{ + "emoji" => %{"bib" => "/test"}, + "tag" => %{"icon" => %{"url" => "/test"}, "name" => ":bib:", "type" => "Emoji"} + } + end + end + + describe "set_replies/1" do + setup do: clear_config([:activitypub, :note_replies_output_limit], 2) + + test "returns unmodified object if activity doesn't have self-replies" do + data = Jason.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) + assert Transmogrifier.set_replies(data) == data + end + + test "sets `replies` collection with a limited number of self-replies" do + [user, another_user] = insert_list(2, :user) + + {:ok, %{id: id1} = activity} = CommonAPI.post(user, %{status: "1"}) + + {:ok, %{id: id2} = self_reply1} = + CommonAPI.post(user, %{status: "self-reply 1", in_reply_to_status_id: id1}) + + {:ok, self_reply2} = + CommonAPI.post(user, %{status: "self-reply 2", in_reply_to_status_id: id1}) + + # Assuming to _not_ be present in `replies` due to :note_replies_output_limit is set to 2 + {:ok, _} = CommonAPI.post(user, %{status: "self-reply 3", in_reply_to_status_id: id1}) + + {:ok, _} = + CommonAPI.post(user, %{ + status: "self-reply to self-reply", + in_reply_to_status_id: id2 + }) + + {:ok, _} = + CommonAPI.post(another_user, %{ + status: "another user's reply", + in_reply_to_status_id: id1 + }) + + object = Object.normalize(activity) + replies_uris = Enum.map([self_reply1, self_reply2], fn a -> a.object.data["id"] end) + + assert %{"type" => "Collection", "items" => ^replies_uris} = + Transmogrifier.set_replies(object.data)["replies"] + end + end + + test "take_emoji_tags/1" do + user = insert(:user, %{emoji: %{"firefox" => "https://example.org/firefox.png"}}) + + assert Transmogrifier.take_emoji_tags(user) == [ + %{ + "icon" => %{"type" => "Image", "url" => "https://example.org/firefox.png"}, + "id" => "https://example.org/firefox.png", + "name" => ":firefox:", + "type" => "Emoji", + "updated" => "1970-01-01T00:00:00Z" + } + ] + end +end diff --git a/test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs index d2822ce75..47f92cf4d 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs @@ -18,7 +18,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.QuestionHandlingTest do end test "Mastodon Question activity" do - data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!() + data = File.read!("test/fixtures/mastodon-question-activity.json") |> Jason.decode!() {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) @@ -97,7 +97,7 @@ test "Mastodon Question activity with HTML tags in plaintext" do data = File.read!("test/fixtures/mastodon-question-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Kernel.put_in(["object", "oneOf"], options) {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) @@ -142,7 +142,7 @@ test "Mastodon Question activity with custom emojis" do data = File.read!("test/fixtures/mastodon-question-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Kernel.put_in(["object", "oneOf"], options) |> Kernel.put_in(["object", "tag"], tag) @@ -158,7 +158,7 @@ test "Mastodon Question activity with custom emojis" do end test "returns same activity if received a second time" do - data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!() + data = File.read!("test/fixtures/mastodon-question-activity.json") |> Jason.decode!() assert {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) @@ -168,7 +168,7 @@ test "returns same activity if received a second time" do test "accepts a Question with no content" do data = File.read!("test/fixtures/mastodon-question-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Kernel.put_in(["object", "content"], "") assert {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(data) diff --git a/test/pleroma/web/activity_pub/transmogrifier/reject_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/reject_handling_test.exs index 5c1451def..851236758 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/reject_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/reject_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.RejectHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.User @@ -18,7 +18,7 @@ test "it fails for incoming rejects which cannot be correlated" do accept_data = File.read!("test/fixtures/mastodon-reject-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", followed.ap_id) accept_data = @@ -35,14 +35,14 @@ test "it works for incoming rejects which are referenced by IRI only" do follower = insert(:user) followed = insert(:user, is_locked: true) - {:ok, follower} = User.follow(follower, followed) + {:ok, follower, followed} = User.follow(follower, followed) {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) assert User.following?(follower, followed) == true reject_data = File.read!("test/fixtures/mastodon-reject-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", followed.ap_id) |> Map.put("object", follow_activity.data["id"]) @@ -58,7 +58,7 @@ test "it rejects activities without a valid ID" do data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) |> Map.put("id", "") diff --git a/test/pleroma/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/undo_handling_test.exs index 8683f7135..107121ef8 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/undo_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/undo_handling_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.UndoHandlingTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.Object @@ -21,7 +21,7 @@ test "it works for incoming emoji reaction undos" do data = File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", reaction_activity.data["id"]) |> Map.put("actor", user.ap_id) @@ -38,7 +38,7 @@ test "it returns an error for incoming unlikes wihout a like activity" do data = File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) assert Transmogrifier.handle_incoming(data) == :error @@ -50,7 +50,7 @@ test "it works for incoming unlikes with an existing like activity" do like_data = File.read!("test/fixtures/mastodon-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) _liker = insert(:user, ap_id: like_data["actor"], local: false) @@ -59,7 +59,7 @@ test "it works for incoming unlikes with an existing like activity" do data = File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", like_data) |> Map.put("actor", like_data["actor"]) @@ -81,7 +81,7 @@ test "it works for incoming unlikes with an existing like activity and a compact like_data = File.read!("test/fixtures/mastodon-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) _liker = insert(:user, ap_id: like_data["actor"], local: false) @@ -90,7 +90,7 @@ test "it works for incoming unlikes with an existing like activity and a compact data = File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", like_data["id"]) |> Map.put("actor", like_data["actor"]) @@ -108,7 +108,7 @@ test "it works for incoming unannounces with an existing notice" do announce_data = File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) _announcer = insert(:user, ap_id: announce_data["actor"], local: false) @@ -118,7 +118,7 @@ test "it works for incoming unannounces with an existing notice" do data = File.read!("test/fixtures/mastodon-undo-announce.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", announce_data) |> Map.put("actor", announce_data["actor"]) @@ -135,7 +135,7 @@ test "it works for incoming unfollows with an existing follow" do follow_data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) _follower = insert(:user, ap_id: follow_data["actor"], local: false) @@ -144,7 +144,7 @@ test "it works for incoming unfollows with an existing follow" do data = File.read!("test/fixtures/mastodon-unfollow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", follow_data) {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) @@ -162,7 +162,7 @@ test "it works for incoming unblocks with an existing block" do block_data = File.read!("test/fixtures/mastodon-block-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) _blocker = insert(:user, ap_id: block_data["actor"], local: false) @@ -171,7 +171,7 @@ test "it works for incoming unblocks with an existing block" do data = File.read!("test/fixtures/mastodon-unblock-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", block_data) {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) diff --git a/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs index 7c4d16db7..8ed5e5e90 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs @@ -14,7 +14,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do test "it works for incoming update activities" do user = insert(:user, local: false) - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() object = update_data["object"] @@ -58,7 +58,7 @@ test "it works with alsoKnownAs" do {:ok, _activity} = "test/fixtures/mastodon-update.json" |> File.read!() - |> Poison.decode!() + |> Jason.decode!() |> Map.put("actor", actor) |> Map.update!("object", fn object -> object @@ -82,7 +82,7 @@ test "it works with custom profile fields" do assert user.fields == [] - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() object = update_data["object"] @@ -103,7 +103,7 @@ test "it works with custom profile fields" do %{"name" => "foo1", "value" => "updated"} ] - Pleroma.Config.put([:instance, :max_remote_account_fields], 2) + clear_config([:instance, :max_remote_account_fields], 2) update_data = update_data @@ -138,7 +138,7 @@ test "it works with custom profile fields" do test "it works for incoming update activities which lock the account" do user = insert(:user, local: false) - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() object = update_data["object"] diff --git a/test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs index 69c953a2e..57411fafa 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs @@ -54,6 +54,7 @@ test "it remaps video URLs as attachments if necessary" do "type" => "Link", "mediaType" => "video/mp4", "name" => nil, + "blurhash" => nil, "url" => [ %{ "href" => @@ -76,6 +77,7 @@ test "it remaps video URLs as attachments if necessary" do "type" => "Link", "mediaType" => "video/mp4", "name" => nil, + "blurhash" => nil, "url" => [ %{ "href" => diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 561674f01..66ea7664a 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -26,313 +26,19 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do setup do: clear_config([:instance, :max_remote_account_fields]) describe "handle_incoming" do - test "it works for incoming notices with tag not being an array (kroeg)" do - data = File.read!("test/fixtures/kroeg-array-less-emoji.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - object = Object.normalize(data["object"]) - - assert object.data["emoji"] == %{ - "icon_e_smile" => "https://puckipedia.com/forum/images/smilies/icon_e_smile.png" - } - - data = File.read!("test/fixtures/kroeg-array-less-hashtag.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - object = Object.normalize(data["object"]) - - assert "test" in object.data["tag"] - end - - test "it cleans up incoming notices which are not really DMs" do - user = insert(:user) - other_user = insert(:user) - - to = [user.ap_id, other_user.ap_id] - - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - |> Map.put("to", to) - |> Map.put("cc", []) - - object = - data["object"] - |> Map.put("to", to) - |> Map.put("cc", []) - - data = Map.put(data, "object", object) - - {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data) - - assert data["to"] == [] - assert data["cc"] == to - - object_data = Object.normalize(activity).data - - assert object_data["to"] == [] - assert object_data["cc"] == to - end - - test "it ignores an incoming notice if we already have it" do - activity = insert(:note_activity) - - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - |> Map.put("object", Object.normalize(activity).data) - - {:ok, returned_activity} = Transmogrifier.handle_incoming(data) - - assert activity == returned_activity - end - - @tag capture_log: true - test "it fetches reply-to activities if we don't have them" do - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - - object = - data["object"] - |> Map.put("inReplyTo", "https://mstdn.io/users/mayuutann/statuses/99568293732299394") - - data = Map.put(data, "object", object) - {:ok, returned_activity} = Transmogrifier.handle_incoming(data) - returned_object = Object.normalize(returned_activity, false) - - assert activity = - Activity.get_create_by_object_ap_id( - "https://mstdn.io/users/mayuutann/statuses/99568293732299394" - ) - - assert returned_object.data["inReplyTo"] == - "https://mstdn.io/users/mayuutann/statuses/99568293732299394" - end - - test "it does not fetch reply-to activities beyond max replies depth limit" do - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - - object = - data["object"] - |> Map.put("inReplyTo", "https://shitposter.club/notice/2827873") - - data = Map.put(data, "object", object) - - with_mock Pleroma.Web.Federator, - allowed_thread_distance?: fn _ -> false end do - {:ok, returned_activity} = Transmogrifier.handle_incoming(data) - - returned_object = Object.normalize(returned_activity, false) - - refute Activity.get_create_by_object_ap_id( - "tag:shitposter.club,2017-05-05:noticeId=2827873:objectType=comment" - ) - - assert returned_object.data["inReplyTo"] == "https://shitposter.club/notice/2827873" - end - end - - test "it does not crash if the object in inReplyTo can't be fetched" do - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - - object = - data["object"] - |> Map.put("inReplyTo", "https://404.site/whatever") - - data = - data - |> Map.put("object", object) - - assert capture_log(fn -> - {:ok, _returned_activity} = Transmogrifier.handle_incoming(data) - end) =~ "[warn] Couldn't fetch \"https://404.site/whatever\", error: nil" - end - - test "it does not work for deactivated users" do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() - - insert(:user, ap_id: data["actor"], deactivated: true) - - assert {:error, _} = Transmogrifier.handle_incoming(data) - end - - test "it works for incoming notices" do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["id"] == - "http://mastodon.example.org/users/admin/statuses/99512778738411822/activity" - - assert data["context"] == - "tag:mastodon.example.org,2018-02-12:objectId=20:objectType=Conversation" - - assert data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] - - assert data["cc"] == [ - "http://mastodon.example.org/users/admin/followers", - "http://localtesting.pleroma.lol/users/lain" - ] - - assert data["actor"] == "http://mastodon.example.org/users/admin" - - object_data = Object.normalize(data["object"]).data - - assert object_data["id"] == - "http://mastodon.example.org/users/admin/statuses/99512778738411822" - - assert object_data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] - - assert object_data["cc"] == [ - "http://mastodon.example.org/users/admin/followers", - "http://localtesting.pleroma.lol/users/lain" - ] - - assert object_data["actor"] == "http://mastodon.example.org/users/admin" - assert object_data["attributedTo"] == "http://mastodon.example.org/users/admin" - - assert object_data["context"] == - "tag:mastodon.example.org,2018-02-12:objectId=20:objectType=Conversation" - - assert object_data["sensitive"] == true - - user = User.get_cached_by_ap_id(object_data["actor"]) - - assert user.note_count == 1 - end - - test "it works for incoming notices with hashtags" do - data = File.read!("test/fixtures/mastodon-post-activity-hashtag.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - object = Object.normalize(data["object"]) - - assert Enum.at(object.data["tag"], 2) == "moo" - end - - test "it works for incoming notices with contentMap" do - data = - File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - object = Object.normalize(data["object"]) - - assert object.data["content"] == - "

    @lain

    " - end - - test "it works for incoming notices with to/cc not being an array (kroeg)" do - data = File.read!("test/fixtures/kroeg-post-activity.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - object = Object.normalize(data["object"]) - - assert object.data["content"] == - "

    henlo from my Psion netBook

    message sent from my Psion netBook

    " - end - - test "it ensures that as:Public activities make it to their followers collection" do - user = insert(:user) - - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - |> Map.put("actor", user.ap_id) - |> Map.put("to", ["https://www.w3.org/ns/activitystreams#Public"]) - |> Map.put("cc", []) - - object = - data["object"] - |> Map.put("attributedTo", user.ap_id) - |> Map.put("to", ["https://www.w3.org/ns/activitystreams#Public"]) - |> Map.put("cc", []) - |> Map.put("id", user.ap_id <> "/activities/12345678") - - data = Map.put(data, "object", object) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["cc"] == [User.ap_followers(user)] - end - - test "it ensures that address fields become lists" do - user = insert(:user) - - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - |> Map.put("actor", user.ap_id) - |> Map.put("to", nil) - |> Map.put("cc", nil) - - object = - data["object"] - |> Map.put("attributedTo", user.ap_id) - |> Map.put("to", nil) - |> Map.put("cc", nil) - |> Map.put("id", user.ap_id <> "/activities/12345678") - - data = Map.put(data, "object", object) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert !is_nil(data["to"]) - assert !is_nil(data["cc"]) - end - - test "it strips internal likes" do - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - - likes = %{ - "first" => - "http://mastodon.example.org/objects/dbdbc507-52c8-490d-9b7c-1e1d52e5c132/likes?page=1", - "id" => "http://mastodon.example.org/objects/dbdbc507-52c8-490d-9b7c-1e1d52e5c132/likes", - "totalItems" => 3, - "type" => "OrderedCollection" - } - - object = Map.put(data["object"], "likes", likes) - data = Map.put(data, "object", object) - - {:ok, %Activity{object: object}} = Transmogrifier.handle_incoming(data) - - refute Map.has_key?(object.data, "likes") - end - - test "it strips internal reactions" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) - {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "๐Ÿ“ข") - - %{object: object} = Activity.get_by_id_with_object(activity.id) - assert Map.has_key?(object.data, "reactions") - assert Map.has_key?(object.data, "reaction_count") - - object_data = Transmogrifier.strip_internal_fields(object.data) - refute Map.has_key?(object_data, "reactions") - refute Map.has_key?(object_data, "reaction_count") - end - test "it works for incoming unfollows with an existing follow" do user = insert(:user) follow_data = File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", user.ap_id) {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(follow_data) data = File.read!("test/fixtures/mastodon-unfollow-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", follow_data) {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) @@ -377,73 +83,6 @@ test "it accepts Flag activities" do assert activity.data["cc"] == [user.ap_id] end - test "it correctly processes messages with non-array to field" do - user = insert(:user) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => "https://www.w3.org/ns/activitystreams#Public", - "type" => "Create", - "object" => %{ - "content" => "blah blah blah", - "type" => "Note", - "attributedTo" => user.ap_id, - "inReplyTo" => nil - }, - "actor" => user.ap_id - } - - assert {:ok, activity} = Transmogrifier.handle_incoming(message) - - assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["to"] - end - - test "it correctly processes messages with non-array cc field" do - user = insert(:user) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => user.follower_address, - "cc" => "https://www.w3.org/ns/activitystreams#Public", - "type" => "Create", - "object" => %{ - "content" => "blah blah blah", - "type" => "Note", - "attributedTo" => user.ap_id, - "inReplyTo" => nil - }, - "actor" => user.ap_id - } - - assert {:ok, activity} = Transmogrifier.handle_incoming(message) - - assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"] - assert [user.follower_address] == activity.data["to"] - end - - test "it correctly processes messages with weirdness in address fields" do - user = insert(:user) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => [nil, user.follower_address], - "cc" => ["https://www.w3.org/ns/activitystreams#Public", ["ยฟ"]], - "type" => "Create", - "object" => %{ - "content" => "โ€ฆ", - "type" => "Note", - "attributedTo" => user.ap_id, - "inReplyTo" => nil - }, - "actor" => user.ap_id - } - - assert {:ok, activity} = Transmogrifier.handle_incoming(message) - - assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"] - assert [user.follower_address] == activity.data["to"] - end - test "it accepts Move activities" do old_user = insert(:user) new_user = insert(:user) @@ -469,95 +108,6 @@ test "it accepts Move activities" do end end - describe "`handle_incoming/2`, Mastodon format `replies` handling" do - setup do: clear_config([:activitypub, :note_replies_output_limit], 5) - setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) - - setup do - data = - "test/fixtures/mastodon-post-activity.json" - |> File.read!() - |> Poison.decode!() - - items = get_in(data, ["object", "replies", "first", "items"]) - assert length(items) > 0 - - %{data: data, items: items} - end - - test "schedules background fetching of `replies` items if max thread depth limit allows", %{ - data: data, - items: items - } do - Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 10) - - {:ok, _activity} = Transmogrifier.handle_incoming(data) - - for id <- items do - job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1} - assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args) - end - end - - test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows", - %{data: data} do - Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0) - - {:ok, _activity} = Transmogrifier.handle_incoming(data) - - assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == [] - end - end - - describe "`handle_incoming/2`, Pleroma format `replies` handling" do - setup do: clear_config([:activitypub, :note_replies_output_limit], 5) - setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) - - setup do - user = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{status: "post1"}) - - {:ok, reply1} = - CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id}) - - {:ok, reply2} = - CommonAPI.post(user, %{status: "reply2", in_reply_to_status_id: activity.id}) - - replies_uris = Enum.map([reply1, reply2], fn a -> a.object.data["id"] end) - - {:ok, federation_output} = Transmogrifier.prepare_outgoing(activity.data) - - Repo.delete(activity.object) - Repo.delete(activity) - - %{federation_output: federation_output, replies_uris: replies_uris} - end - - test "schedules background fetching of `replies` items if max thread depth limit allows", %{ - federation_output: federation_output, - replies_uris: replies_uris - } do - Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 1) - - {:ok, _activity} = Transmogrifier.handle_incoming(federation_output) - - for id <- replies_uris do - job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1} - assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args) - end - end - - test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows", - %{federation_output: federation_output} do - Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0) - - {:ok, _activity} = Transmogrifier.handle_incoming(federation_output) - - assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == [] - end - end - describe "prepare outgoing" do test "it inlines private announced objects" do user = insert(:user) @@ -854,60 +404,6 @@ test "it rejects activities which reference objects that have an incorrect attri end end - describe "reserialization" do - test "successfully reserializes a message with inReplyTo == nil" do - user = insert(:user) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "cc" => [], - "type" => "Create", - "object" => %{ - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "cc" => [], - "type" => "Note", - "content" => "Hi", - "inReplyTo" => nil, - "attributedTo" => user.ap_id - }, - "actor" => user.ap_id - } - - {:ok, activity} = Transmogrifier.handle_incoming(message) - - {:ok, _} = Transmogrifier.prepare_outgoing(activity.data) - end - - test "successfully reserializes a message with AS2 objects in IR" do - user = insert(:user) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "cc" => [], - "type" => "Create", - "object" => %{ - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "cc" => [], - "type" => "Note", - "content" => "Hi", - "inReplyTo" => nil, - "attributedTo" => user.ap_id, - "tag" => [ - %{"name" => "#2hu", "href" => "http://example.com/2hu", "type" => "Hashtag"}, - %{"name" => "Bob", "href" => "http://example.com/bob", "type" => "Mention"} - ] - }, - "actor" => user.ap_id - } - - {:ok, activity} = Transmogrifier.handle_incoming(message) - - {:ok, _} = Transmogrifier.prepare_outgoing(activity.data) - end - end - describe "fix_explicit_addressing" do setup do user = insert(:user) @@ -973,64 +469,6 @@ test "returns fixed object" do end end - describe "fix_in_reply_to/2" do - setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) - - setup do - data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) - [data: data] - end - - test "returns not modified object when hasn't containts inReplyTo field", %{data: data} do - assert Transmogrifier.fix_in_reply_to(data) == data - end - - test "returns object with inReplyTo when denied incoming reply", %{data: data} do - Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0) - - object_with_reply = - Map.put(data["object"], "inReplyTo", "https://shitposter.club/notice/2827873") - - modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) - assert modified_object["inReplyTo"] == "https://shitposter.club/notice/2827873" - - object_with_reply = - Map.put(data["object"], "inReplyTo", %{"id" => "https://shitposter.club/notice/2827873"}) - - modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) - assert modified_object["inReplyTo"] == %{"id" => "https://shitposter.club/notice/2827873"} - - object_with_reply = - Map.put(data["object"], "inReplyTo", ["https://shitposter.club/notice/2827873"]) - - modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) - assert modified_object["inReplyTo"] == ["https://shitposter.club/notice/2827873"] - - object_with_reply = Map.put(data["object"], "inReplyTo", []) - modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) - assert modified_object["inReplyTo"] == [] - end - - @tag capture_log: true - test "returns modified object when allowed incoming reply", %{data: data} do - object_with_reply = - Map.put( - data["object"], - "inReplyTo", - "https://mstdn.io/users/mayuutann/statuses/99568293732299394" - ) - - Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 5) - modified_object = Transmogrifier.fix_in_reply_to(object_with_reply) - - assert modified_object["inReplyTo"] == - "https://mstdn.io/users/mayuutann/statuses/99568293732299394" - - assert modified_object["context"] == - "tag:shitposter.club,2018-02-22:objectType=thread:nonce=e5a7c72d60a9c0e4" - end - end - describe "fix_url/1" do test "fixes data for object when url is map" do object = %{ @@ -1066,155 +504,4 @@ test "returns {:ok, %Object{}} for success case" do ) end end - - describe "fix_attachments/1" do - test "returns not modified object" do - data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) - assert Transmogrifier.fix_attachments(data) == data - end - - test "returns modified object when attachment is map" do - assert Transmogrifier.fix_attachments(%{ - "attachment" => %{ - "mediaType" => "video/mp4", - "url" => "https://peertube.moe/stat-480.mp4" - } - }) == %{ - "attachment" => [ - %{ - "mediaType" => "video/mp4", - "type" => "Document", - "url" => [ - %{ - "href" => "https://peertube.moe/stat-480.mp4", - "mediaType" => "video/mp4", - "type" => "Link" - } - ] - } - ] - } - end - - test "returns modified object when attachment is list" do - assert Transmogrifier.fix_attachments(%{ - "attachment" => [ - %{"mediaType" => "video/mp4", "url" => "https://pe.er/stat-480.mp4"}, - %{"mimeType" => "video/mp4", "href" => "https://pe.er/stat-480.mp4"} - ] - }) == %{ - "attachment" => [ - %{ - "mediaType" => "video/mp4", - "type" => "Document", - "url" => [ - %{ - "href" => "https://pe.er/stat-480.mp4", - "mediaType" => "video/mp4", - "type" => "Link" - } - ] - }, - %{ - "mediaType" => "video/mp4", - "type" => "Document", - "url" => [ - %{ - "href" => "https://pe.er/stat-480.mp4", - "mediaType" => "video/mp4", - "type" => "Link" - } - ] - } - ] - } - end - end - - describe "fix_emoji/1" do - test "returns not modified object when object not contains tags" do - data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) - assert Transmogrifier.fix_emoji(data) == data - end - - test "returns object with emoji when object contains list tags" do - assert Transmogrifier.fix_emoji(%{ - "tag" => [ - %{"type" => "Emoji", "name" => ":bib:", "icon" => %{"url" => "/test"}}, - %{"type" => "Hashtag"} - ] - }) == %{ - "emoji" => %{"bib" => "/test"}, - "tag" => [ - %{"icon" => %{"url" => "/test"}, "name" => ":bib:", "type" => "Emoji"}, - %{"type" => "Hashtag"} - ] - } - end - - test "returns object with emoji when object contains map tag" do - assert Transmogrifier.fix_emoji(%{ - "tag" => %{"type" => "Emoji", "name" => ":bib:", "icon" => %{"url" => "/test"}} - }) == %{ - "emoji" => %{"bib" => "/test"}, - "tag" => %{"icon" => %{"url" => "/test"}, "name" => ":bib:", "type" => "Emoji"} - } - end - end - - describe "set_replies/1" do - setup do: clear_config([:activitypub, :note_replies_output_limit], 2) - - test "returns unmodified object if activity doesn't have self-replies" do - data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) - assert Transmogrifier.set_replies(data) == data - end - - test "sets `replies` collection with a limited number of self-replies" do - [user, another_user] = insert_list(2, :user) - - {:ok, %{id: id1} = activity} = CommonAPI.post(user, %{status: "1"}) - - {:ok, %{id: id2} = self_reply1} = - CommonAPI.post(user, %{status: "self-reply 1", in_reply_to_status_id: id1}) - - {:ok, self_reply2} = - CommonAPI.post(user, %{status: "self-reply 2", in_reply_to_status_id: id1}) - - # Assuming to _not_ be present in `replies` due to :note_replies_output_limit is set to 2 - {:ok, _} = CommonAPI.post(user, %{status: "self-reply 3", in_reply_to_status_id: id1}) - - {:ok, _} = - CommonAPI.post(user, %{ - status: "self-reply to self-reply", - in_reply_to_status_id: id2 - }) - - {:ok, _} = - CommonAPI.post(another_user, %{ - status: "another user's reply", - in_reply_to_status_id: id1 - }) - - object = Object.normalize(activity) - replies_uris = Enum.map([self_reply1, self_reply2], fn a -> a.object.data["id"] end) - - assert %{"type" => "Collection", "items" => ^replies_uris} = - Transmogrifier.set_replies(object.data)["replies"] - end - end - - test "take_emoji_tags/1" do - user = insert(:user, %{emoji: %{"firefox" => "https://example.org/firefox.png"}}) - - assert Transmogrifier.take_emoji_tags(user) == [ - %{ - "icon" => %{"type" => "Image", "url" => "https://example.org/firefox.png"}, - "id" => "https://example.org/firefox.png", - "name" => ":firefox:", - "type" => "Emoji", - "updated" => "1970-01-01T00:00:00Z" - } - ] - end end diff --git a/test/pleroma/web/activity_pub/utils_test.exs b/test/pleroma/web/activity_pub/utils_test.exs index be9cd7d13..2263b6091 100644 --- a/test/pleroma/web/activity_pub/utils_test.exs +++ b/test/pleroma/web/activity_pub/utils_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.UtilsTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.Object alias Pleroma.Repo diff --git a/test/pleroma/web/activity_pub/views/user_view_test.exs b/test/pleroma/web/activity_pub/views/user_view_test.exs index 98c7c9d09..fe6ddf0d6 100644 --- a/test/pleroma/web/activity_pub/views/user_view_test.exs +++ b/test/pleroma/web/activity_pub/views/user_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.UserViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.User diff --git a/test/pleroma/web/activity_pub/visibility_test.exs b/test/pleroma/web/activity_pub/visibility_test.exs index 8e9354c65..1ec41aa19 100644 --- a/test/pleroma/web/activity_pub/visibility_test.exs +++ b/test/pleroma/web/activity_pub/visibility_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.VisibilityTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Activity alias Pleroma.Web.ActivityPub.Visibility @@ -15,7 +15,7 @@ defmodule Pleroma.Web.ActivityPub.VisibilityTest do mentioned = insert(:user) following = insert(:user) unrelated = insert(:user) - {:ok, following} = Pleroma.User.follow(following, user) + {:ok, following, user} = Pleroma.User.follow(following, user) {:ok, list} = Pleroma.List.create("foo", user) Pleroma.List.follow(list, unrelated) @@ -159,7 +159,7 @@ test "doesn't die when the user doesn't exist", user: user } do Repo.delete(user) - Cachex.clear(:user_cache) + Pleroma.User.invalidate_cache(user) refute Visibility.is_private?(direct) end diff --git a/test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs b/test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs index cba6b43d3..e50d1425b 100644 --- a/test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/admin_api_controller_test.exs @@ -7,22 +7,16 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do use Oban.Testing, repo: Pleroma.Repo import ExUnit.CaptureLog - import Mock import Pleroma.Factory import Swoosh.TestAssertions alias Pleroma.Activity - alias Pleroma.Config - alias Pleroma.HTML alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User - alias Pleroma.Web - alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MediaProxy setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -153,300 +147,6 @@ test "GET /api/pleroma/admin/users/:nickname requires " <> end end - describe "DELETE /api/pleroma/admin/users" do - test "single user", %{admin: admin, conn: conn} do - clear_config([:instance, :federating], true) - - user = - insert(:user, - avatar: %{"url" => [%{"href" => "https://someurl"}]}, - banner: %{"url" => [%{"href" => "https://somebanner"}]}, - bio: "Hello world!", - name: "A guy" - ) - - # Create some activities to check they got deleted later - follower = insert(:user) - {:ok, _} = CommonAPI.post(user, %{status: "test"}) - {:ok, _, _, _} = CommonAPI.follow(user, follower) - {:ok, _, _, _} = CommonAPI.follow(follower, user) - user = Repo.get(User, user.id) - assert user.note_count == 1 - assert user.follower_count == 1 - assert user.following_count == 1 - refute user.deactivated - - with_mock Pleroma.Web.Federator, - publish: fn _ -> nil end, - perform: fn _, _ -> nil end do - conn = - conn - |> put_req_header("accept", "application/json") - |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}") - - ObanHelpers.perform_all() - - assert User.get_by_nickname(user.nickname).deactivated - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deleted users: @#{user.nickname}" - - assert json_response(conn, 200) == [user.nickname] - - user = Repo.get(User, user.id) - assert user.deactivated - - assert user.avatar == %{} - assert user.banner == %{} - assert user.note_count == 0 - assert user.follower_count == 0 - assert user.following_count == 0 - assert user.bio == "" - assert user.name == nil - - assert called(Pleroma.Web.Federator.publish(:_)) - end - end - - test "multiple users", %{admin: admin, conn: conn} do - user_one = insert(:user) - user_two = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> delete("/api/pleroma/admin/users", %{ - nicknames: [user_one.nickname, user_two.nickname] - }) - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deleted users: @#{user_one.nickname}, @#{user_two.nickname}" - - response = json_response(conn, 200) - assert response -- [user_one.nickname, user_two.nickname] == [] - end - end - - describe "/api/pleroma/admin/users" do - test "Create", %{conn: conn} do - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{ - "nickname" => "lain", - "email" => "lain@example.org", - "password" => "test" - }, - %{ - "nickname" => "lain2", - "email" => "lain2@example.org", - "password" => "test" - } - ] - }) - - response = json_response(conn, 200) |> Enum.map(&Map.get(&1, "type")) - assert response == ["success", "success"] - - log_entry = Repo.one(ModerationLog) - - assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == [] - end - - test "Cannot create user with existing email", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{ - "nickname" => "lain", - "email" => user.email, - "password" => "test" - } - ] - }) - - assert json_response(conn, 409) == [ - %{ - "code" => 409, - "data" => %{ - "email" => user.email, - "nickname" => "lain" - }, - "error" => "email has already been taken", - "type" => "error" - } - ] - end - - test "Cannot create user with existing nickname", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{ - "nickname" => user.nickname, - "email" => "someuser@plerama.social", - "password" => "test" - } - ] - }) - - assert json_response(conn, 409) == [ - %{ - "code" => 409, - "data" => %{ - "email" => "someuser@plerama.social", - "nickname" => user.nickname - }, - "error" => "nickname has already been taken", - "type" => "error" - } - ] - end - - test "Multiple user creation works in transaction", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{ - "nickname" => "newuser", - "email" => "newuser@pleroma.social", - "password" => "test" - }, - %{ - "nickname" => "lain", - "email" => user.email, - "password" => "test" - } - ] - }) - - assert json_response(conn, 409) == [ - %{ - "code" => 409, - "data" => %{ - "email" => user.email, - "nickname" => "lain" - }, - "error" => "email has already been taken", - "type" => "error" - }, - %{ - "code" => 409, - "data" => %{ - "email" => "newuser@pleroma.social", - "nickname" => "newuser" - }, - "error" => "", - "type" => "error" - } - ] - - assert User.get_by_nickname("newuser") === nil - end - end - - describe "/api/pleroma/admin/users/:nickname" do - test "Show", %{conn: conn} do - user = insert(:user) - - conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") - - expected = %{ - "deactivated" => false, - "id" => to_string(user.id), - "local" => true, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - - assert expected == json_response(conn, 200) - end - - test "when the user doesn't exist", %{conn: conn} do - user = build(:user) - - conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") - - assert %{"error" => "Not found"} == json_response(conn, 404) - end - end - - describe "/api/pleroma/admin/users/follow" do - test "allows to force-follow another user", %{admin: admin, conn: conn} do - user = insert(:user) - follower = insert(:user) - - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users/follow", %{ - "follower" => follower.nickname, - "followed" => user.nickname - }) - - user = User.get_cached_by_id(user.id) - follower = User.get_cached_by_id(follower.id) - - assert User.following?(follower, user) - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} made @#{follower.nickname} follow @#{user.nickname}" - end - end - - describe "/api/pleroma/admin/users/unfollow" do - test "allows to force-unfollow another user", %{admin: admin, conn: conn} do - user = insert(:user) - follower = insert(:user) - - User.follow(follower, user) - - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users/unfollow", %{ - "follower" => follower.nickname, - "followed" => user.nickname - }) - - user = User.get_cached_by_id(user.id) - follower = User.get_cached_by_id(follower.id) - - refute User.following?(follower, user) - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} made @#{follower.nickname} unfollow @#{user.nickname}" - end - end - describe "PUT /api/pleroma/admin/users/tag" do setup %{conn: conn} do user1 = insert(:user, %{tags: ["x"]}) @@ -643,753 +343,6 @@ test "/api/pleroma/admin/users/:nickname/password_reset", %{conn: conn} do assert Regex.match?(~r/(http:\/\/|https:\/\/)/, resp["link"]) end - describe "GET /api/pleroma/admin/users" do - test "renders users array for the first page", %{conn: conn, admin: admin} do - user = insert(:user, local: false, tags: ["foo", "bar"]) - user2 = insert(:user, approval_pending: true, registration_reason: "I'm a chill dude") - - conn = get(conn, "/api/pleroma/admin/users?page=1") - - users = - [ - %{ - "deactivated" => admin.deactivated, - "id" => admin.id, - "nickname" => admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => admin.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - }, - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => false, - "tags" => ["foo", "bar"], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - }, - %{ - "deactivated" => user2.deactivated, - "id" => user2.id, - "nickname" => user2.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user2) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false, - "approval_pending" => true, - "url" => user2.ap_id, - "registration_reason" => "I'm a chill dude", - "actor_type" => "Person" - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 3, - "page_size" => 50, - "users" => users - } - end - - test "pagination works correctly with service users", %{conn: conn} do - service1 = User.get_or_create_service_actor_by_ap_id(Web.base_url() <> "/meido", "meido") - - insert_list(25, :user) - - assert %{"count" => 26, "page_size" => 10, "users" => users1} = - conn - |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"}) - |> json_response(200) - - assert Enum.count(users1) == 10 - assert service1 not in users1 - - assert %{"count" => 26, "page_size" => 10, "users" => users2} = - conn - |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"}) - |> json_response(200) - - assert Enum.count(users2) == 10 - assert service1 not in users2 - - assert %{"count" => 26, "page_size" => 10, "users" => users3} = - conn - |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"}) - |> json_response(200) - - assert Enum.count(users3) == 6 - assert service1 not in users3 - end - - test "renders empty array for the second page", %{conn: conn} do - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?page=2") - - assert json_response(conn, 200) == %{ - "count" => 2, - "page_size" => 50, - "users" => [] - } - end - - test "regular search", %{conn: conn} do - user = insert(:user, nickname: "bob") - - conn = get(conn, "/api/pleroma/admin/users?query=bo") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "search by domain", %{conn: conn} do - user = insert(:user, nickname: "nickname@domain.com") - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?query=domain.com") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "search by full nickname", %{conn: conn} do - user = insert(:user, nickname: "nickname@domain.com") - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?query=nickname@domain.com") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "search by display name", %{conn: conn} do - user = insert(:user, name: "Display name") - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?name=display") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "search by email", %{conn: conn} do - user = insert(:user, email: "email@example.com") - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?email=email@example.com") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "regular search with page size", %{conn: conn} do - user = insert(:user, nickname: "aalice") - user2 = insert(:user, nickname: "alice") - - conn1 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=1") - - assert json_response(conn1, 200) == %{ - "count" => 2, - "page_size" => 1, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - - conn2 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=2") - - assert json_response(conn2, 200) == %{ - "count" => 2, - "page_size" => 1, - "users" => [ - %{ - "deactivated" => user2.deactivated, - "id" => user2.id, - "nickname" => user2.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user2) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user2.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "only local users" do - admin = insert(:user, is_admin: true, nickname: "john") - token = insert(:oauth_admin_token, user: admin) - user = insert(:user, nickname: "bob") - - insert(:user, nickname: "bobb", local: false) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - |> get("/api/pleroma/admin/users?query=bo&filters=local") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "only local users with no query", %{conn: conn, admin: old_admin} do - admin = insert(:user, is_admin: true, nickname: "john") - user = insert(:user, nickname: "bob") - - insert(:user, nickname: "bobb", local: false) - - conn = get(conn, "/api/pleroma/admin/users?filters=local") - - users = - [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - }, - %{ - "deactivated" => admin.deactivated, - "id" => admin.id, - "nickname" => admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => admin.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - }, - %{ - "deactivated" => false, - "id" => old_admin.id, - "local" => true, - "nickname" => old_admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "tags" => [], - "avatar" => User.avatar_url(old_admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => old_admin.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 3, - "page_size" => 50, - "users" => users - } - end - - test "only unapproved users", %{conn: conn} do - user = - insert(:user, - nickname: "sadboy", - approval_pending: true, - registration_reason: "Plz let me in!" - ) - - insert(:user, nickname: "happyboy", approval_pending: false) - - conn = get(conn, "/api/pleroma/admin/users?filters=need_approval") - - users = - [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => true, - "url" => user.ap_id, - "registration_reason" => "Plz let me in!", - "actor_type" => "Person" - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => users - } - end - - test "load only admins", %{conn: conn, admin: admin} do - second_admin = insert(:user, is_admin: true) - insert(:user) - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?filters=is_admin") - - users = - [ - %{ - "deactivated" => false, - "id" => admin.id, - "nickname" => admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => admin.local, - "tags" => [], - "avatar" => User.avatar_url(admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => admin.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - }, - %{ - "deactivated" => false, - "id" => second_admin.id, - "nickname" => second_admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => second_admin.local, - "tags" => [], - "avatar" => User.avatar_url(second_admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => second_admin.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 2, - "page_size" => 50, - "users" => users - } - end - - test "load only moderators", %{conn: conn} do - moderator = insert(:user, is_moderator: true) - insert(:user) - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?filters=is_moderator") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => false, - "id" => moderator.id, - "nickname" => moderator.nickname, - "roles" => %{"admin" => false, "moderator" => true}, - "local" => moderator.local, - "tags" => [], - "avatar" => User.avatar_url(moderator) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(moderator.name || moderator.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => moderator.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "load users with tags list", %{conn: conn} do - user1 = insert(:user, tags: ["first"]) - user2 = insert(:user, tags: ["second"]) - insert(:user) - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?tags[]=first&tags[]=second") - - users = - [ - %{ - "deactivated" => false, - "id" => user1.id, - "nickname" => user1.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => user1.local, - "tags" => ["first"], - "avatar" => User.avatar_url(user1) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user1.name || user1.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user1.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - }, - %{ - "deactivated" => false, - "id" => user2.id, - "nickname" => user2.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => user2.local, - "tags" => ["second"], - "avatar" => User.avatar_url(user2) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user2.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 2, - "page_size" => 50, - "users" => users - } - end - - test "`active` filters out users pending approval", %{token: token} do - insert(:user, approval_pending: true) - %{id: user_id} = insert(:user, approval_pending: false) - %{id: admin_id} = token.user - - conn = - build_conn() - |> assign(:user, token.user) - |> assign(:token, token) - |> get("/api/pleroma/admin/users?filters=active") - - assert %{ - "count" => 2, - "page_size" => 50, - "users" => [ - %{"id" => ^admin_id}, - %{"id" => ^user_id} - ] - } = json_response(conn, 200) - end - - test "it works with multiple filters" do - admin = insert(:user, nickname: "john", is_admin: true) - token = insert(:oauth_admin_token, user: admin) - user = insert(:user, nickname: "bob", local: false, deactivated: true) - - insert(:user, nickname: "ken", local: true, deactivated: true) - insert(:user, nickname: "bobb", local: false, deactivated: false) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - |> get("/api/pleroma/admin/users?filters=deactivated,external") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => user.local, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - - test "it omits relay user", %{admin: admin, conn: conn} do - assert %User{} = Relay.get_actor() - - conn = get(conn, "/api/pleroma/admin/users") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => admin.deactivated, - "id" => admin.id, - "nickname" => admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => admin.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - ] - } - end - end - - test "PATCH /api/pleroma/admin/users/activate", %{admin: admin, conn: conn} do - user_one = insert(:user, deactivated: true) - user_two = insert(:user, deactivated: true) - - conn = - patch( - conn, - "/api/pleroma/admin/users/activate", - %{nicknames: [user_one.nickname, user_two.nickname]} - ) - - response = json_response(conn, 200) - assert Enum.map(response["users"], & &1["deactivated"]) == [false, false] - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} activated users: @#{user_one.nickname}, @#{user_two.nickname}" - end - - test "PATCH /api/pleroma/admin/users/deactivate", %{admin: admin, conn: conn} do - user_one = insert(:user, deactivated: false) - user_two = insert(:user, deactivated: false) - - conn = - patch( - conn, - "/api/pleroma/admin/users/deactivate", - %{nicknames: [user_one.nickname, user_two.nickname]} - ) - - response = json_response(conn, 200) - assert Enum.map(response["users"], & &1["deactivated"]) == [true, true] - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deactivated users: @#{user_one.nickname}, @#{user_two.nickname}" - end - - test "PATCH /api/pleroma/admin/users/approve", %{admin: admin, conn: conn} do - user_one = insert(:user, approval_pending: true) - user_two = insert(:user, approval_pending: true) - - conn = - patch( - conn, - "/api/pleroma/admin/users/approve", - %{nicknames: [user_one.nickname, user_two.nickname]} - ) - - response = json_response(conn, 200) - assert Enum.map(response["users"], & &1["approval_pending"]) == [false, false] - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} approved users: @#{user_one.nickname}, @#{user_two.nickname}" - end - - test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admin, conn: conn} do - user = insert(:user) - - conn = patch(conn, "/api/pleroma/admin/users/#{user.nickname}/toggle_activation") - - assert json_response(conn, 200) == - %{ - "deactivated" => !user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false, - "approval_pending" => false, - "url" => user.ap_id, - "registration_reason" => nil, - "actor_type" => "Person" - } - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deactivated users: @#{user.nickname}" - end - describe "PUT disable_mfa" do test "returns 200 and disable 2fa", %{conn: conn} do user = @@ -1891,8 +844,8 @@ test "sets password_reset_pending to true", %{conn: conn} do describe "instances" do test "GET /instances/:instance/statuses", %{conn: conn} do - user = insert(:user, local: false, nickname: "archaeme@archae.me") - user2 = insert(:user, local: false, nickname: "test@test.com") + user = insert(:user, local: false, ap_id: "https://archae.me/users/archaeme") + user2 = insert(:user, local: false, ap_id: "https://test.com/users/test") insert_pair(:note_activity, user: user) activity = insert(:note_activity, user: user2) @@ -1988,7 +941,6 @@ test "it resend emails for two users", %{conn: conn, admin: admin} do describe "/api/pleroma/admin/stats" do test "status visibility count", %{conn: conn} do - admin = insert(:user, is_admin: true) user = insert(:user) CommonAPI.post(user, %{visibility: "public", status: "hey"}) CommonAPI.post(user, %{visibility: "unlisted", status: "hey"}) @@ -1996,7 +948,6 @@ test "status visibility count", %{conn: conn} do response = conn - |> assign(:user, admin) |> get("/api/pleroma/admin/stats") |> json_response(200) @@ -2005,7 +956,6 @@ test "status visibility count", %{conn: conn} do end test "by instance", %{conn: conn} do - admin = insert(:user, is_admin: true) user1 = insert(:user) instance2 = "instance2.tld" user2 = insert(:user, %{ap_id: "https://#{instance2}/@actor"}) @@ -2016,7 +966,6 @@ test "by instance", %{conn: conn} do response = conn - |> assign(:user, admin) |> get("/api/pleroma/admin/stats", instance: instance2) |> json_response(200) @@ -2024,6 +973,73 @@ test "by instance", %{conn: conn} do response["status_visibility"] end end + + describe "/api/pleroma/backups" do + test "it creates a backup", %{conn: conn} do + admin = %{id: admin_id, nickname: admin_nickname} = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + user = %{id: user_id, nickname: user_nickname} = insert(:user) + + assert "" == + conn + |> assign(:user, admin) + |> assign(:token, token) + |> post("/api/pleroma/admin/backups", %{nickname: user.nickname}) + |> json_response(200) + + assert [backup] = Repo.all(Pleroma.User.Backup) + + ObanHelpers.perform_all() + + email = Pleroma.Emails.UserEmail.backup_is_ready_email(backup, admin.id) + + assert String.contains?(email.html_body, "Admin @#{admin.nickname} requested a full backup") + assert_email_sent(to: {user.name, user.email}, html_body: email.html_body) + + log_message = "@#{admin_nickname} requested account backup for @#{user_nickname}" + + assert [ + %{ + data: %{ + "action" => "create_backup", + "actor" => %{ + "id" => ^admin_id, + "nickname" => ^admin_nickname + }, + "message" => ^log_message, + "subject" => %{ + "id" => ^user_id, + "nickname" => ^user_nickname + } + } + } + ] = Pleroma.ModerationLog |> Repo.all() + end + + test "it doesn't limit admins", %{conn: conn} do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + user = insert(:user) + + assert "" == + conn + |> assign(:user, admin) + |> assign(:token, token) + |> post("/api/pleroma/admin/backups", %{nickname: user.nickname}) + |> json_response(200) + + assert [_backup] = Repo.all(Pleroma.User.Backup) + + assert "" == + conn + |> assign(:user, admin) + |> assign(:token, token) + |> post("/api/pleroma/admin/backups", %{nickname: user.nickname}) + |> json_response(200) + + assert Repo.aggregate(Pleroma.User.Backup, :count) == 2 + end + end end # Needed for testing diff --git a/test/pleroma/web/admin_api/controllers/chat_controller_test.exs b/test/pleroma/web/admin_api/controllers/chat_controller_test.exs index bd4c9c9d1..dead1c09e 100644 --- a/test/pleroma/web/admin_api/controllers/chat_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/chat_controller_test.exs @@ -3,13 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.ChatControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory alias Pleroma.Chat alias Pleroma.Chat.MessageReference - alias Pleroma.Config alias Pleroma.ModerationLog alias Pleroma.Object alias Pleroma.Repo diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs index 4e897455f..df5d74d45 100644 --- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.ConfigControllerTest do - use Pleroma.Web.ConnCase, async: true + use Pleroma.Web.ConnCase import ExUnit.CaptureLog import Pleroma.Factory @@ -162,7 +162,9 @@ test "with valid `admin_token` query parameter, skips OAuth scopes check" do end end - test "POST /api/pleroma/admin/config error", %{conn: conn} do + test "POST /api/pleroma/admin/config with configdb disabled", %{conn: conn} do + clear_config(:configurable_from_database, false) + conn = conn |> put_req_header("content-type", "application/json") @@ -1415,11 +1417,7 @@ test "enables the welcome messages", %{conn: conn} do describe "GET /api/pleroma/admin/config/descriptions" do test "structure", %{conn: conn} do - admin = insert(:user, is_admin: true) - - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") + conn = get(conn, "/api/pleroma/admin/config/descriptions") assert [child | _others] = json_response_and_validate_schema(conn, 200) @@ -1437,11 +1435,7 @@ test "filters by database configuration whitelist", %{conn: conn} do {:esshd} ]) - admin = insert(:user, is_admin: true) - - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") + conn = get(conn, "/api/pleroma/admin/config/descriptions") children = json_response_and_validate_schema(conn, 200) diff --git a/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs b/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs new file mode 100644 index 000000000..94873f6db --- /dev/null +++ b/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs @@ -0,0 +1,141 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.FrontendControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.Config + + @dir "test/frontend_static_test" + + setup do + clear_config([:instance, :static_dir], @dir) + File.mkdir_p!(Pleroma.Frontend.dir()) + + on_exit(fn -> + File.rm_rf(@dir) + end) + + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/frontends" do + test "it lists available frontends", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/frontends") + |> json_response_and_validate_schema(:ok) + + assert Enum.map(response, & &1["name"]) == + Enum.map(Config.get([:frontends, :available]), fn {_, map} -> map["name"] end) + + refute Enum.any?(response, fn frontend -> frontend["installed"] == true end) + end + end + + describe "POST /api/pleroma/admin/frontends/install" do + test "from available frontends", %{conn: conn} do + clear_config([:frontends, :available], %{ + "pleroma" => %{ + "ref" => "fantasy", + "name" => "pleroma", + "build_url" => "http://gensokyo.2hu/builds/${ref}" + } + }) + + Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/builds/fantasy"} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend_dist.zip")} + end) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/frontends/install", %{name: "pleroma"}) + |> json_response_and_validate_schema(:ok) + + assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"])) + + response = + conn + |> get("/api/pleroma/admin/frontends") + |> json_response_and_validate_schema(:ok) + + assert response == [ + %{ + "build_url" => "http://gensokyo.2hu/builds/${ref}", + "git" => nil, + "installed" => true, + "name" => "pleroma", + "ref" => "fantasy" + } + ] + end + + test "from a file", %{conn: conn} do + clear_config([:frontends, :available], %{ + "pleroma" => %{ + "ref" => "fantasy", + "name" => "pleroma", + "build_dir" => "" + } + }) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/frontends/install", %{ + name: "pleroma", + file: "test/fixtures/tesla_mock/frontend.zip" + }) + |> json_response_and_validate_schema(:ok) + + assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"])) + end + + test "from an URL", %{conn: conn} do + Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")} + end) + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/frontends/install", %{ + name: "unknown", + ref: "baka", + build_url: "http://gensokyo.2hu/madeup.zip", + build_dir: "" + }) + |> json_response_and_validate_schema(:ok) + + assert File.exists?(Path.join([@dir, "frontends", "unknown", "baka", "test.txt"])) + end + + test "failing returns an error", %{conn: conn} do + Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} -> + %Tesla.Env{status: 404, body: ""} + end) + + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/frontends/install", %{ + name: "unknown", + ref: "baka", + build_url: "http://gensokyo.2hu/madeup.zip", + build_dir: "" + }) + |> json_response_and_validate_schema(400) + + assert result == %{"error" => "Could not download or unzip the frontend"} + end + end +end diff --git a/test/pleroma/web/admin_api/controllers/instance_document_controller_test.exs b/test/pleroma/web/admin_api/controllers/instance_document_controller_test.exs index 5f7b042f6..ce867dd0e 100644 --- a/test/pleroma/web/admin_api/controllers/instance_document_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/instance_document_controller_test.exs @@ -5,7 +5,6 @@ defmodule Pleroma.Web.AdminAPI.InstanceDocumentControllerTest do use Pleroma.Web.ConnCase, async: true import Pleroma.Factory - alias Pleroma.Config @dir "test/tmp/instance_static" @default_instance_panel ~s(

    Welcome to Pleroma!

    ) diff --git a/test/pleroma/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/pleroma/web/admin_api/controllers/media_proxy_cache_controller_test.exs index f243d1fb2..62fb9592a 100644 --- a/test/pleroma/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -12,10 +12,6 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do setup do: clear_config([:media_proxy]) - setup do - on_exit(fn -> Cachex.clear(:banned_urls_cache) end) - end - setup do admin = insert(:user, is_admin: true) token = insert(:oauth_admin_token, user: admin) diff --git a/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs b/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs index ed7c4172c..f388375d1 100644 --- a/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs @@ -8,7 +8,6 @@ defmodule Pleroma.Web.AdminAPI.OAuthAppControllerTest do import Pleroma.Factory - alias Pleroma.Config alias Pleroma.Web setup do diff --git a/test/pleroma/web/admin_api/controllers/relay_controller_test.exs b/test/pleroma/web/admin_api/controllers/relay_controller_test.exs index adadf2b5c..379067a62 100644 --- a/test/pleroma/web/admin_api/controllers/relay_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/relay_controller_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Web.AdminAPI.RelayControllerTest do import Pleroma.Factory - alias Pleroma.Config alias Pleroma.ModerationLog alias Pleroma.Repo alias Pleroma.User @@ -61,7 +60,7 @@ test "GET /relay", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/relay") - assert json_response_and_validate_schema(conn, 200)["relays"] == [ + assert json_response_and_validate_schema(conn, 200)["relays"] |> Enum.sort() == [ %{ "actor" => "http://mastodon.example.org/users/admin", "followed_back" => true diff --git a/test/pleroma/web/admin_api/controllers/report_controller_test.exs b/test/pleroma/web/admin_api/controllers/report_controller_test.exs index 57946e6bb..2ab2f2f6d 100644 --- a/test/pleroma/web/admin_api/controllers/report_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/report_controller_test.exs @@ -3,12 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.ReportControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory alias Pleroma.Activity - alias Pleroma.Config alias Pleroma.ModerationLog alias Pleroma.Repo alias Pleroma.ReportNote @@ -38,12 +37,21 @@ test "returns report by its id", %{conn: conn} do status_ids: [activity.id] }) + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/reports/#{report_id}/notes", %{ + content: "this is an admin note" + }) + response = conn |> get("/api/pleroma/admin/reports/#{report_id}") |> json_response_and_validate_schema(:ok) assert response["id"] == report_id + + [notes] = response["notes"] + assert notes["content"] == "this is an admin note" end test "returns 404 when report id is invalid", %{conn: conn} do @@ -114,13 +122,13 @@ test "mark report as resolved", %{conn: conn, id: id, admin: admin} do }) |> json_response_and_validate_schema(:no_content) - activity = Activity.get_by_id(id) + activity = Activity.get_by_id_with_user_actor(id) assert activity.data["state"] == "resolved" log_entry = Repo.one(ModerationLog) assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated report ##{id} with 'resolved' state" + "@#{admin.nickname} updated report ##{id} (on user @#{activity.user_actor.nickname}) with 'resolved' state" end test "closes report", %{conn: conn, id: id, admin: admin} do @@ -133,13 +141,13 @@ test "closes report", %{conn: conn, id: id, admin: admin} do }) |> json_response_and_validate_schema(:no_content) - activity = Activity.get_by_id(id) + activity = Activity.get_by_id_with_user_actor(id) assert activity.data["state"] == "closed" log_entry = Repo.one(ModerationLog) assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated report ##{id} with 'closed' state" + "@#{admin.nickname} updated report ##{id} (on user @#{activity.user_actor.nickname}) with 'closed' state" end test "returns 400 when state is unknown", %{conn: conn, id: id} do @@ -185,18 +193,20 @@ test "updates state of multiple reports", %{ }) |> json_response_and_validate_schema(:no_content) - activity = Activity.get_by_id(id) - second_activity = Activity.get_by_id(second_report_id) + activity = Activity.get_by_id_with_user_actor(id) + second_activity = Activity.get_by_id_with_user_actor(second_report_id) assert activity.data["state"] == "resolved" assert second_activity.data["state"] == "closed" [first_log_entry, second_log_entry] = Repo.all(ModerationLog) assert ModerationLog.get_log_entry_message(first_log_entry) == - "@#{admin.nickname} updated report ##{id} with 'resolved' state" + "@#{admin.nickname} updated report ##{id} (on user @#{activity.user_actor.nickname}) with 'resolved' state" assert ModerationLog.get_log_entry_message(second_log_entry) == - "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state" + "@#{admin.nickname} updated report ##{second_report_id} (on user @#{ + second_activity.user_actor.nickname + }) with 'closed' state" end end diff --git a/test/pleroma/web/admin_api/controllers/status_controller_test.exs b/test/pleroma/web/admin_api/controllers/status_controller_test.exs index eff78fb0a..40714c8a4 100644 --- a/test/pleroma/web/admin_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/status_controller_test.exs @@ -3,12 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.StatusControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory alias Pleroma.Activity - alias Pleroma.Config alias Pleroma.ModerationLog alias Pleroma.Repo alias Pleroma.User diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs new file mode 100644 index 000000000..5705306c7 --- /dev/null +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -0,0 +1,970 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.UserControllerTest do + use Pleroma.Web.ConnCase + use Oban.Testing, repo: Pleroma.Repo + + import Mock + import Pleroma.Factory + + alias Pleroma.HTML + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.Tests.ObanHelpers + alias Pleroma.User + alias Pleroma.Web + alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MediaProxy + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + + :ok + end + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + test "with valid `admin_token` query parameter, skips OAuth scopes check" do + clear_config([:admin_token], "password123") + + user = insert(:user) + + conn = get(build_conn(), "/api/pleroma/admin/users/#{user.nickname}?admin_token=password123") + + assert json_response(conn, 200) + end + + describe "with [:auth, :enforce_oauth_admin_scope_usage]," do + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true) + + test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or broader scope", + %{admin: admin} do + user = insert(:user) + url = "/api/pleroma/admin/users/#{user.nickname}" + + good_token1 = insert(:oauth_token, user: admin, scopes: ["admin"]) + good_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read"]) + good_token3 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts"]) + + bad_token1 = insert(:oauth_token, user: admin, scopes: ["read:accounts"]) + bad_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts:partial"]) + bad_token3 = nil + + for good_token <- [good_token1, good_token2, good_token3] do + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, good_token) + |> get(url) + + assert json_response(conn, 200) + end + + for good_token <- [good_token1, good_token2, good_token3] do + conn = + build_conn() + |> assign(:user, nil) + |> assign(:token, good_token) + |> get(url) + + assert json_response(conn, :forbidden) + end + + for bad_token <- [bad_token1, bad_token2, bad_token3] do + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, bad_token) + |> get(url) + + assert json_response(conn, :forbidden) + end + end + end + + describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) + + test "GET /api/pleroma/admin/users/:nickname requires " <> + "read:accounts or admin:read:accounts or broader scope", + %{admin: admin} do + user = insert(:user) + url = "/api/pleroma/admin/users/#{user.nickname}" + + good_token1 = insert(:oauth_token, user: admin, scopes: ["admin"]) + good_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read"]) + good_token3 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts"]) + good_token4 = insert(:oauth_token, user: admin, scopes: ["read:accounts"]) + good_token5 = insert(:oauth_token, user: admin, scopes: ["read"]) + + good_tokens = [good_token1, good_token2, good_token3, good_token4, good_token5] + + bad_token1 = insert(:oauth_token, user: admin, scopes: ["read:accounts:partial"]) + bad_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts:partial"]) + bad_token3 = nil + + for good_token <- good_tokens do + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, good_token) + |> get(url) + + assert json_response(conn, 200) + end + + for good_token <- good_tokens do + conn = + build_conn() + |> assign(:user, nil) + |> assign(:token, good_token) + |> get(url) + + assert json_response(conn, :forbidden) + end + + for bad_token <- [bad_token1, bad_token2, bad_token3] do + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, bad_token) + |> get(url) + + assert json_response(conn, :forbidden) + end + end + end + + describe "DELETE /api/pleroma/admin/users" do + test "single user", %{admin: admin, conn: conn} do + clear_config([:instance, :federating], true) + + user = + insert(:user, + avatar: %{"url" => [%{"href" => "https://someurl"}]}, + banner: %{"url" => [%{"href" => "https://somebanner"}]}, + bio: "Hello world!", + name: "A guy" + ) + + # Create some activities to check they got deleted later + follower = insert(:user) + {:ok, _} = CommonAPI.post(user, %{status: "test"}) + {:ok, _, _, _} = CommonAPI.follow(user, follower) + {:ok, _, _, _} = CommonAPI.follow(follower, user) + user = Repo.get(User, user.id) + assert user.note_count == 1 + assert user.follower_count == 1 + assert user.following_count == 1 + refute user.deactivated + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end, + perform: fn _, _ -> nil end do + conn = + conn + |> put_req_header("accept", "application/json") + |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}") + + ObanHelpers.perform_all() + + assert User.get_by_nickname(user.nickname).deactivated + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deleted users: @#{user.nickname}" + + assert json_response(conn, 200) == [user.nickname] + + user = Repo.get(User, user.id) + assert user.deactivated + + assert user.avatar == %{} + assert user.banner == %{} + assert user.note_count == 0 + assert user.follower_count == 0 + assert user.following_count == 0 + assert user.bio == "" + assert user.name == nil + + assert called(Pleroma.Web.Federator.publish(:_)) + end + end + + test "multiple users", %{admin: admin, conn: conn} do + user_one = insert(:user) + user_two = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> delete("/api/pleroma/admin/users", %{ + nicknames: [user_one.nickname, user_two.nickname] + }) + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deleted users: @#{user_one.nickname}, @#{user_two.nickname}" + + response = json_response(conn, 200) + assert response -- [user_one.nickname, user_two.nickname] == [] + end + end + + describe "/api/pleroma/admin/users" do + test "Create", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => "lain", + "email" => "lain@example.org", + "password" => "test" + }, + %{ + "nickname" => "lain2", + "email" => "lain2@example.org", + "password" => "test" + } + ] + }) + + response = json_response(conn, 200) |> Enum.map(&Map.get(&1, "type")) + assert response == ["success", "success"] + + log_entry = Repo.one(ModerationLog) + + assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == [] + end + + test "Cannot create user with existing email", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => "lain", + "email" => user.email, + "password" => "test" + } + ] + }) + + assert json_response(conn, 409) == [ + %{ + "code" => 409, + "data" => %{ + "email" => user.email, + "nickname" => "lain" + }, + "error" => "email has already been taken", + "type" => "error" + } + ] + end + + test "Cannot create user with existing nickname", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => user.nickname, + "email" => "someuser@plerama.social", + "password" => "test" + } + ] + }) + + assert json_response(conn, 409) == [ + %{ + "code" => 409, + "data" => %{ + "email" => "someuser@plerama.social", + "nickname" => user.nickname + }, + "error" => "nickname has already been taken", + "type" => "error" + } + ] + end + + test "Multiple user creation works in transaction", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => "newuser", + "email" => "newuser@pleroma.social", + "password" => "test" + }, + %{ + "nickname" => "lain", + "email" => user.email, + "password" => "test" + } + ] + }) + + assert json_response(conn, 409) == [ + %{ + "code" => 409, + "data" => %{ + "email" => user.email, + "nickname" => "lain" + }, + "error" => "email has already been taken", + "type" => "error" + }, + %{ + "code" => 409, + "data" => %{ + "email" => "newuser@pleroma.social", + "nickname" => "newuser" + }, + "error" => "", + "type" => "error" + } + ] + + assert User.get_by_nickname("newuser") === nil + end + end + + describe "/api/pleroma/admin/users/:nickname" do + test "Show", %{conn: conn} do + user = insert(:user) + + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") + + assert user_response(user) == json_response(conn, 200) + end + + test "when the user doesn't exist", %{conn: conn} do + user = build(:user) + + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") + + assert %{"error" => "Not found"} == json_response(conn, 404) + end + end + + describe "/api/pleroma/admin/users/follow" do + test "allows to force-follow another user", %{admin: admin, conn: conn} do + user = insert(:user) + follower = insert(:user) + + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users/follow", %{ + "follower" => follower.nickname, + "followed" => user.nickname + }) + + user = User.get_cached_by_id(user.id) + follower = User.get_cached_by_id(follower.id) + + assert User.following?(follower, user) + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} made @#{follower.nickname} follow @#{user.nickname}" + end + end + + describe "/api/pleroma/admin/users/unfollow" do + test "allows to force-unfollow another user", %{admin: admin, conn: conn} do + user = insert(:user) + follower = insert(:user) + + User.follow(follower, user) + + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users/unfollow", %{ + "follower" => follower.nickname, + "followed" => user.nickname + }) + + user = User.get_cached_by_id(user.id) + follower = User.get_cached_by_id(follower.id) + + refute User.following?(follower, user) + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} made @#{follower.nickname} unfollow @#{user.nickname}" + end + end + + describe "GET /api/pleroma/admin/users" do + test "renders users array for the first page", %{conn: conn, admin: admin} do + user = insert(:user, local: false, tags: ["foo", "bar"]) + user2 = insert(:user, approval_pending: true, registration_reason: "I'm a chill dude") + + conn = get(conn, "/api/pleroma/admin/users?page=1") + + users = + [ + user_response( + admin, + %{"roles" => %{"admin" => true, "moderator" => false}} + ), + user_response(user, %{"local" => false, "tags" => ["foo", "bar"]}), + user_response( + user2, + %{ + "local" => true, + "approval_pending" => true, + "registration_reason" => "I'm a chill dude", + "actor_type" => "Person" + } + ) + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 3, + "page_size" => 50, + "users" => users + } + end + + test "pagination works correctly with service users", %{conn: conn} do + service1 = User.get_or_create_service_actor_by_ap_id(Web.base_url() <> "/meido", "meido") + + insert_list(25, :user) + + assert %{"count" => 26, "page_size" => 10, "users" => users1} = + conn + |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users1) == 10 + assert service1 not in users1 + + assert %{"count" => 26, "page_size" => 10, "users" => users2} = + conn + |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users2) == 10 + assert service1 not in users2 + + assert %{"count" => 26, "page_size" => 10, "users" => users3} = + conn + |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users3) == 6 + assert service1 not in users3 + end + + test "renders empty array for the second page", %{conn: conn} do + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?page=2") + + assert json_response(conn, 200) == %{ + "count" => 2, + "page_size" => 50, + "users" => [] + } + end + + test "regular search", %{conn: conn} do + user = insert(:user, nickname: "bob") + + conn = get(conn, "/api/pleroma/admin/users?query=bo") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [user_response(user, %{"local" => true})] + } + end + + test "search by domain", %{conn: conn} do + user = insert(:user, nickname: "nickname@domain.com") + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?query=domain.com") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [user_response(user)] + } + end + + test "search by full nickname", %{conn: conn} do + user = insert(:user, nickname: "nickname@domain.com") + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?query=nickname@domain.com") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [user_response(user)] + } + end + + test "search by display name", %{conn: conn} do + user = insert(:user, name: "Display name") + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?name=display") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [user_response(user)] + } + end + + test "search by email", %{conn: conn} do + user = insert(:user, email: "email@example.com") + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?email=email@example.com") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [user_response(user)] + } + end + + test "regular search with page size", %{conn: conn} do + user = insert(:user, nickname: "aalice") + user2 = insert(:user, nickname: "alice") + + conn1 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=1") + + assert json_response(conn1, 200) == %{ + "count" => 2, + "page_size" => 1, + "users" => [user_response(user)] + } + + conn2 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=2") + + assert json_response(conn2, 200) == %{ + "count" => 2, + "page_size" => 1, + "users" => [user_response(user2)] + } + end + + test "only local users" do + admin = insert(:user, is_admin: true, nickname: "john") + token = insert(:oauth_admin_token, user: admin) + user = insert(:user, nickname: "bob") + + insert(:user, nickname: "bobb", local: false) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + |> get("/api/pleroma/admin/users?query=bo&filters=local") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [user_response(user)] + } + end + + test "only local users with no query", %{conn: conn, admin: old_admin} do + admin = insert(:user, is_admin: true, nickname: "john") + user = insert(:user, nickname: "bob") + + insert(:user, nickname: "bobb", local: false) + + conn = get(conn, "/api/pleroma/admin/users?filters=local") + + users = + [ + user_response(user), + user_response(admin, %{ + "roles" => %{"admin" => true, "moderator" => false} + }), + user_response(old_admin, %{ + "deactivated" => false, + "roles" => %{"admin" => true, "moderator" => false} + }) + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 3, + "page_size" => 50, + "users" => users + } + end + + test "only unconfirmed users", %{conn: conn} do + sad_user = insert(:user, nickname: "sadboy", confirmation_pending: true) + old_user = insert(:user, nickname: "oldboy", confirmation_pending: true) + + insert(:user, nickname: "happyboy", approval_pending: false) + insert(:user, confirmation_pending: false) + + result = + conn + |> get("/api/pleroma/admin/users?filters=unconfirmed") + |> json_response(200) + + users = + Enum.map([old_user, sad_user], fn user -> + user_response(user, %{ + "confirmation_pending" => true, + "approval_pending" => false + }) + end) + |> Enum.sort_by(& &1["nickname"]) + + assert result == %{"count" => 2, "page_size" => 50, "users" => users} + end + + test "only unapproved users", %{conn: conn} do + user = + insert(:user, + nickname: "sadboy", + approval_pending: true, + registration_reason: "Plz let me in!" + ) + + insert(:user, nickname: "happyboy", approval_pending: false) + + conn = get(conn, "/api/pleroma/admin/users?filters=need_approval") + + users = [ + user_response( + user, + %{"approval_pending" => true, "registration_reason" => "Plz let me in!"} + ) + ] + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => users + } + end + + test "load only admins", %{conn: conn, admin: admin} do + second_admin = insert(:user, is_admin: true) + insert(:user) + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?filters=is_admin") + + users = + [ + user_response(admin, %{ + "deactivated" => false, + "roles" => %{"admin" => true, "moderator" => false} + }), + user_response(second_admin, %{ + "deactivated" => false, + "roles" => %{"admin" => true, "moderator" => false} + }) + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 2, + "page_size" => 50, + "users" => users + } + end + + test "load only moderators", %{conn: conn} do + moderator = insert(:user, is_moderator: true) + insert(:user) + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?filters=is_moderator") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + user_response(moderator, %{ + "deactivated" => false, + "roles" => %{"admin" => false, "moderator" => true} + }) + ] + } + end + + test "load users with actor_type is Person", %{admin: admin, conn: conn} do + insert(:user, actor_type: "Service") + insert(:user, actor_type: "Application") + + user1 = insert(:user) + user2 = insert(:user) + + response = + conn + |> get(user_path(conn, :list), %{actor_types: ["Person"]}) + |> json_response(200) + + users = + [ + user_response(admin, %{"roles" => %{"admin" => true, "moderator" => false}}), + user_response(user1), + user_response(user2) + ] + |> Enum.sort_by(& &1["nickname"]) + + assert response == %{"count" => 3, "page_size" => 50, "users" => users} + end + + test "load users with actor_type is Person and Service", %{admin: admin, conn: conn} do + user_service = insert(:user, actor_type: "Service") + insert(:user, actor_type: "Application") + + user1 = insert(:user) + user2 = insert(:user) + + response = + conn + |> get(user_path(conn, :list), %{actor_types: ["Person", "Service"]}) + |> json_response(200) + + users = + [ + user_response(admin, %{"roles" => %{"admin" => true, "moderator" => false}}), + user_response(user1), + user_response(user2), + user_response(user_service, %{"actor_type" => "Service"}) + ] + |> Enum.sort_by(& &1["nickname"]) + + assert response == %{"count" => 4, "page_size" => 50, "users" => users} + end + + test "load users with actor_type is Service", %{conn: conn} do + user_service = insert(:user, actor_type: "Service") + insert(:user, actor_type: "Application") + insert(:user) + insert(:user) + + response = + conn + |> get(user_path(conn, :list), %{actor_types: ["Service"]}) + |> json_response(200) + + users = [user_response(user_service, %{"actor_type" => "Service"})] + + assert response == %{"count" => 1, "page_size" => 50, "users" => users} + end + + test "load users with tags list", %{conn: conn} do + user1 = insert(:user, tags: ["first"]) + user2 = insert(:user, tags: ["second"]) + insert(:user) + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?tags[]=first&tags[]=second") + + users = + [ + user_response(user1, %{"tags" => ["first"]}), + user_response(user2, %{"tags" => ["second"]}) + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 2, + "page_size" => 50, + "users" => users + } + end + + test "`active` filters out users pending approval", %{token: token} do + insert(:user, approval_pending: true) + %{id: user_id} = insert(:user, approval_pending: false) + %{id: admin_id} = token.user + + conn = + build_conn() + |> assign(:user, token.user) + |> assign(:token, token) + |> get("/api/pleroma/admin/users?filters=active") + + assert %{ + "count" => 2, + "page_size" => 50, + "users" => [ + %{"id" => ^admin_id}, + %{"id" => ^user_id} + ] + } = json_response(conn, 200) + end + + test "it works with multiple filters" do + admin = insert(:user, nickname: "john", is_admin: true) + token = insert(:oauth_admin_token, user: admin) + user = insert(:user, nickname: "bob", local: false, deactivated: true) + + insert(:user, nickname: "ken", local: true, deactivated: true) + insert(:user, nickname: "bobb", local: false, deactivated: false) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + |> get("/api/pleroma/admin/users?filters=deactivated,external") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [user_response(user)] + } + end + + test "it omits relay user", %{admin: admin, conn: conn} do + assert %User{} = Relay.get_actor() + + conn = get(conn, "/api/pleroma/admin/users") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + user_response(admin, %{"roles" => %{"admin" => true, "moderator" => false}}) + ] + } + end + end + + test "PATCH /api/pleroma/admin/users/activate", %{admin: admin, conn: conn} do + user_one = insert(:user, deactivated: true) + user_two = insert(:user, deactivated: true) + + conn = + patch( + conn, + "/api/pleroma/admin/users/activate", + %{nicknames: [user_one.nickname, user_two.nickname]} + ) + + response = json_response(conn, 200) + assert Enum.map(response["users"], & &1["deactivated"]) == [false, false] + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} activated users: @#{user_one.nickname}, @#{user_two.nickname}" + end + + test "PATCH /api/pleroma/admin/users/deactivate", %{admin: admin, conn: conn} do + user_one = insert(:user, deactivated: false) + user_two = insert(:user, deactivated: false) + + conn = + patch( + conn, + "/api/pleroma/admin/users/deactivate", + %{nicknames: [user_one.nickname, user_two.nickname]} + ) + + response = json_response(conn, 200) + assert Enum.map(response["users"], & &1["deactivated"]) == [true, true] + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deactivated users: @#{user_one.nickname}, @#{user_two.nickname}" + end + + test "PATCH /api/pleroma/admin/users/approve", %{admin: admin, conn: conn} do + user_one = insert(:user, approval_pending: true) + user_two = insert(:user, approval_pending: true) + + conn = + patch( + conn, + "/api/pleroma/admin/users/approve", + %{nicknames: [user_one.nickname, user_two.nickname]} + ) + + response = json_response(conn, 200) + assert Enum.map(response["users"], & &1["approval_pending"]) == [false, false] + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} approved users: @#{user_one.nickname}, @#{user_two.nickname}" + end + + test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admin, conn: conn} do + user = insert(:user) + + conn = patch(conn, "/api/pleroma/admin/users/#{user.nickname}/toggle_activation") + + assert json_response(conn, 200) == + user_response( + user, + %{"deactivated" => !user.deactivated} + ) + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deactivated users: @#{user.nickname}" + end + + defp user_response(user, attrs \\ %{}) do + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => user.local, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false, + "approval_pending" => false, + "url" => user.ap_id, + "registration_reason" => nil, + "actor_type" => "Person" + } + |> Map.merge(attrs) + end +end diff --git a/test/pleroma/web/admin_api/search_test.exs b/test/pleroma/web/admin_api/search_test.exs index d88867c52..fdf22a8e6 100644 --- a/test/pleroma/web/admin_api/search_test.exs +++ b/test/pleroma/web/admin_api/search_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.SearchTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Web.AdminAPI.Search @@ -143,6 +143,20 @@ test "it returns users with tags" do assert user2 in users end + test "it returns users by actor_types" do + user_service = insert(:user, actor_type: "Service") + user_application = insert(:user, actor_type: "Application") + user1 = insert(:user) + user2 = insert(:user) + + {:ok, [^user_service], 1} = Search.user(%{actor_types: ["Service"]}) + {:ok, [^user_application], 1} = Search.user(%{actor_types: ["Application"]}) + {:ok, [^user1, ^user2], 2} = Search.user(%{actor_types: ["Person"]}) + + {:ok, [^user_service, ^user1, ^user2], 3} = + Search.user(%{actor_types: ["Person", "Service"]}) + end + test "it returns user by display name" do user = insert(:user, name: "Display name") insert(:user) @@ -178,9 +192,21 @@ test "it returns unapproved user" do assert count == 1 end + test "it returns unconfirmed user" do + unconfirmed = insert(:user, confirmation_pending: true) + insert(:user) + insert(:user) + + {:ok, _results, total} = Search.user() + {:ok, [^unconfirmed], count} = Search.user(%{unconfirmed: true}) + assert total == 3 + assert count == 1 + end + + # Note: as in Mastodon, `is_discoverable` doesn't anyhow relate to user searchability test "it returns non-discoverable users" do insert(:user) - insert(:user, discoverable: false) + insert(:user, is_discoverable: false) {:ok, _results, total} = Search.user() diff --git a/test/pleroma/web/admin_api/views/moderation_log_view_test.exs b/test/pleroma/web/admin_api/views/moderation_log_view_test.exs new file mode 100644 index 000000000..390d7bbeb --- /dev/null +++ b/test/pleroma/web/admin_api/views/moderation_log_view_test.exs @@ -0,0 +1,98 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.AdminAPI.ModerationLogViewTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Web.AdminAPI.ModerationLogView + + describe "renders `report_note_delete` log messages" do + setup do + log1 = %Pleroma.ModerationLog{ + data: %{ + "action" => "report_note_delete", + "actor" => %{"id" => "A1I7G8", "nickname" => "admin", "type" => "user"}, + "message" => "@admin deleted note 'mistake' from report #A1I7be on user @b-612", + "subject" => %{"id" => "A1I7be", "state" => "open", "type" => "report"}, + "subject_actor" => %{"id" => "A1I7G8", "nickname" => "b-612", "type" => "user"}, + "text" => "mistake" + }, + inserted_at: ~N[2020-11-17 14:13:20] + } + + log2 = %Pleroma.ModerationLog{ + data: %{ + "action" => "report_note_delete", + "actor" => %{"id" => "A1I7G8", "nickname" => "admin", "type" => "user"}, + "message" => "@admin deleted note 'fake user' from report #A1I7be on user @j-612", + "subject" => %{"id" => "A1I7be", "state" => "open", "type" => "report"}, + "subject_actor" => %{"id" => "A1I7G8", "nickname" => "j-612", "type" => "user"}, + "text" => "fake user" + }, + inserted_at: ~N[2020-11-17 14:13:20] + } + + {:ok, %{log1: log1, log2: log2}} + end + + test "renders `report_note_delete` log messages", %{log1: log1, log2: log2} do + assert ModerationLogView.render( + "index.json", + %{log: %{items: [log1, log2], count: 2}} + ) == %{ + items: [ + %{ + data: %{ + "action" => "report_note_delete", + "actor" => %{"id" => "A1I7G8", "nickname" => "admin", "type" => "user"}, + "message" => + "@admin deleted note 'mistake' from report #A1I7be on user @b-612", + "subject" => %{"id" => "A1I7be", "state" => "open", "type" => "report"}, + "subject_actor" => %{ + "id" => "A1I7G8", + "nickname" => "b-612", + "type" => "user" + }, + "text" => "mistake" + }, + message: "@admin deleted note 'mistake' from report #A1I7be on user @b-612", + time: 1_605_622_400 + }, + %{ + data: %{ + "action" => "report_note_delete", + "actor" => %{"id" => "A1I7G8", "nickname" => "admin", "type" => "user"}, + "message" => + "@admin deleted note 'fake user' from report #A1I7be on user @j-612", + "subject" => %{"id" => "A1I7be", "state" => "open", "type" => "report"}, + "subject_actor" => %{ + "id" => "A1I7G8", + "nickname" => "j-612", + "type" => "user" + }, + "text" => "fake user" + }, + message: "@admin deleted note 'fake user' from report #A1I7be on user @j-612", + time: 1_605_622_400 + } + ], + total: 2 + } + end + + test "renders `report_note_delete` log message", %{log1: log} do + assert ModerationLogView.render("show.json", %{log_entry: log}) == %{ + data: %{ + "action" => "report_note_delete", + "actor" => %{"id" => "A1I7G8", "nickname" => "admin", "type" => "user"}, + "message" => "@admin deleted note 'mistake' from report #A1I7be on user @b-612", + "subject" => %{"id" => "A1I7be", "state" => "open", "type" => "report"}, + "subject_actor" => %{"id" => "A1I7G8", "nickname" => "b-612", "type" => "user"}, + "text" => "mistake" + }, + message: "@admin deleted note 'mistake' from report #A1I7be on user @b-612", + time: 1_605_622_400 + } + end + end +end diff --git a/test/pleroma/web/admin_api/views/report_view_test.exs b/test/pleroma/web/admin_api/views/report_view_test.exs index 5a02292be..ff3453208 100644 --- a/test/pleroma/web/admin_api/views/report_view_test.exs +++ b/test/pleroma/web/admin_api/views/report_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.AdminAPI.ReportViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory diff --git a/test/pleroma/web/auth/authenticator_test.exs b/test/pleroma/web/auth/authenticator_test.exs index d54253343..862eb8051 100644 --- a/test/pleroma/web/auth/authenticator_test.exs +++ b/test/pleroma/web/auth/authenticator_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.AuthenticatorTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Web.Auth.Authenticator import Pleroma.Factory diff --git a/test/pleroma/web/auth/basic_auth_test.exs b/test/pleroma/web/auth/basic_auth_test.exs index bf6e3d2fc..e56c1e1e8 100644 --- a/test/pleroma/web/auth/basic_auth_test.exs +++ b/test/pleroma/web/auth/basic_auth_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.BasicAuthTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory diff --git a/test/pleroma/web/auth/pleroma_authenticator_test.exs b/test/pleroma/web/auth/pleroma_authenticator_test.exs index 1ba0dfecc..4539ffe87 100644 --- a/test/pleroma/web/auth/pleroma_authenticator_test.exs +++ b/test/pleroma/web/auth/pleroma_authenticator_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Web.Auth.PleromaAuthenticator import Pleroma.Factory diff --git a/test/pleroma/web/auth/totp_authenticator_test.exs b/test/pleroma/web/auth/totp_authenticator_test.exs index 84d4cd840..7f99d62bf 100644 --- a/test/pleroma/web/auth/totp_authenticator_test.exs +++ b/test/pleroma/web/auth/totp_authenticator_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.TOTPAuthenticatorTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.MFA alias Pleroma.MFA.BackupCodes diff --git a/test/pleroma/web/common_api/utils_test.exs b/test/pleroma/web/common_api/utils_test.exs index e67c10b93..4d6c9ea26 100644 --- a/test/pleroma/web/common_api/utils_test.exs +++ b/test/pleroma/web/common_api/utils_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do alias Pleroma.Builders.UserBuilder alias Pleroma.Object alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.ActivityDraft alias Pleroma.Web.CommonAPI.Utils use Pleroma.DataCase @@ -235,9 +236,9 @@ test "when date is a random string" do test "for public posts, not a reply" do user = insert(:user) mentioned_user = insert(:user) - mentions = [mentioned_user.ap_id] + draft = %ActivityDraft{user: user, mentions: [mentioned_user.ap_id], visibility: "public"} - {to, cc} = Utils.get_to_and_cc(user, mentions, nil, "public", nil) + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 2 assert length(cc) == 1 @@ -252,9 +253,15 @@ test "for public posts, a reply" do mentioned_user = insert(:user) third_user = insert(:user) {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"}) - mentions = [mentioned_user.ap_id] - {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "public", nil) + draft = %ActivityDraft{ + user: user, + mentions: [mentioned_user.ap_id], + visibility: "public", + in_reply_to: activity + } + + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 3 assert length(cc) == 1 @@ -268,9 +275,9 @@ test "for public posts, a reply" do test "for unlisted posts, not a reply" do user = insert(:user) mentioned_user = insert(:user) - mentions = [mentioned_user.ap_id] + draft = %ActivityDraft{user: user, mentions: [mentioned_user.ap_id], visibility: "unlisted"} - {to, cc} = Utils.get_to_and_cc(user, mentions, nil, "unlisted", nil) + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 2 assert length(cc) == 1 @@ -285,9 +292,15 @@ test "for unlisted posts, a reply" do mentioned_user = insert(:user) third_user = insert(:user) {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"}) - mentions = [mentioned_user.ap_id] - {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "unlisted", nil) + draft = %ActivityDraft{ + user: user, + mentions: [mentioned_user.ap_id], + visibility: "unlisted", + in_reply_to: activity + } + + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 3 assert length(cc) == 1 @@ -301,9 +314,9 @@ test "for unlisted posts, a reply" do test "for private posts, not a reply" do user = insert(:user) mentioned_user = insert(:user) - mentions = [mentioned_user.ap_id] + draft = %ActivityDraft{user: user, mentions: [mentioned_user.ap_id], visibility: "private"} - {to, cc} = Utils.get_to_and_cc(user, mentions, nil, "private", nil) + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 2 assert Enum.empty?(cc) @@ -316,9 +329,15 @@ test "for private posts, a reply" do mentioned_user = insert(:user) third_user = insert(:user) {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"}) - mentions = [mentioned_user.ap_id] - {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "private", nil) + draft = %ActivityDraft{ + user: user, + mentions: [mentioned_user.ap_id], + visibility: "private", + in_reply_to: activity + } + + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 2 assert Enum.empty?(cc) @@ -330,9 +349,9 @@ test "for private posts, a reply" do test "for direct posts, not a reply" do user = insert(:user) mentioned_user = insert(:user) - mentions = [mentioned_user.ap_id] + draft = %ActivityDraft{user: user, mentions: [mentioned_user.ap_id], visibility: "direct"} - {to, cc} = Utils.get_to_and_cc(user, mentions, nil, "direct", nil) + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 1 assert Enum.empty?(cc) @@ -345,9 +364,15 @@ test "for direct posts, a reply" do mentioned_user = insert(:user) third_user = insert(:user) {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"}) - mentions = [mentioned_user.ap_id] - {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "direct", nil) + draft = %ActivityDraft{ + user: user, + mentions: [mentioned_user.ap_id], + visibility: "direct", + in_reply_to: activity + } + + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 1 assert Enum.empty?(cc) @@ -356,7 +381,14 @@ test "for direct posts, a reply" do {:ok, direct_activity} = CommonAPI.post(third_user, %{status: "uguu", visibility: "direct"}) - {to, cc} = Utils.get_to_and_cc(user, mentions, direct_activity, "direct", nil) + draft = %ActivityDraft{ + user: user, + mentions: [mentioned_user.ap_id], + visibility: "direct", + in_reply_to: direct_activity + } + + {to, cc} = Utils.get_to_and_cc(draft) assert length(to) == 2 assert Enum.empty?(cc) @@ -532,26 +564,26 @@ test "returns original params when list not found" do end end - describe "make_note_data/11" do + describe "make_note_data/1" do test "returns note data" do user = insert(:user) note = insert(:note) user2 = insert(:user) user3 = insert(:user) - assert Utils.make_note_data( - user.ap_id, - [user2.ap_id], - "2hu", - "

    This is :moominmamma: note

    ", - [], - note.id, - [name: "jimm"], - "test summary", - [user3.ap_id], - false, - %{"custom_tag" => "test"} - ) == %{ + draft = %ActivityDraft{ + user: user, + to: [user2.ap_id], + context: "2hu", + content_html: "

    This is :moominmamma: note

    ", + in_reply_to: note.id, + tags: [name: "jimm"], + summary: "test summary", + cc: [user3.ap_id], + extra: %{"custom_tag" => "test"} + } + + assert Utils.make_note_data(draft) == %{ "actor" => user.ap_id, "attachment" => [], "cc" => [user3.ap_id], diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index f5d09f396..585b2c174 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -3,8 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.CommonAPITest do - use Pleroma.DataCase use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase alias Pleroma.Activity alias Pleroma.Chat @@ -95,6 +95,20 @@ test "it blocks and does not federate if outgoing blocks are disabled", %{ describe "posting chat messages" do setup do: clear_config([:instance, :chat_limit]) + test "it posts a self-chat" do + author = insert(:user) + recipient = author + + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + "remember to buy milk when milk truk arive" + ) + + assert activity.data["type"] == "Create" + end + test "it posts a chat message without content but with an attachment" do author = insert(:user) recipient = insert(:user) @@ -622,7 +636,7 @@ test "it validates character limits are correctly enforced" do assert {:error, "The status is over the character limit"} = CommonAPI.post(user, %{status: "foobar"}) - assert {:ok, activity} = CommonAPI.post(user, %{status: "12345"}) + assert {:ok, _activity} = CommonAPI.post(user, %{status: "12345"}) end test "it can handle activities that expire" do @@ -908,12 +922,34 @@ test "add mute", %{user: user, activity: activity} do assert CommonAPI.thread_muted?(user, activity) end + test "add expiring mute", %{user: user, activity: activity} do + {:ok, _} = CommonAPI.add_mute(user, activity, %{expires_in: 60}) + assert CommonAPI.thread_muted?(user, activity) + + worker = Pleroma.Workers.MuteExpireWorker + args = %{"op" => "unmute_conversation", "user_id" => user.id, "activity_id" => activity.id} + + assert_enqueued( + worker: worker, + args: args + ) + + assert :ok = perform_job(worker, args) + refute CommonAPI.thread_muted?(user, activity) + end + test "remove mute", %{user: user, activity: activity} do CommonAPI.add_mute(user, activity) {:ok, _} = CommonAPI.remove_mute(user, activity) refute CommonAPI.thread_muted?(user, activity) end + test "remove mute by ids", %{user: user, activity: activity} do + CommonAPI.add_mute(user, activity) + {:ok, _} = CommonAPI.remove_mute(user.id, activity.id) + refute CommonAPI.thread_muted?(user, activity) + end + test "check that mutes can't be duplicate", %{user: user, activity: activity} do CommonAPI.add_mute(user, activity) {:error, _} = CommonAPI.add_mute(user, activity) @@ -1241,4 +1277,128 @@ test "fallback" do } = CommonAPI.get_user("") end end + + describe "with `local` visibility" do + setup do: clear_config([:instance, :federating], true) + + test "post" do + user = insert(:user) + + with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do + {:ok, activity} = CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"}) + + assert Visibility.is_local_public?(activity) + assert_not_called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "delete" do + user = insert(:user) + + {:ok, %Activity{id: activity_id}} = + CommonAPI.post(user, %{status: "#2hu #2HU", visibility: "local"}) + + with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do + assert {:ok, %Activity{data: %{"deleted_activity_id" => ^activity_id}} = activity} = + CommonAPI.delete(activity_id, user) + + assert Visibility.is_local_public?(activity) + assert_not_called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "repeat" do + user = insert(:user) + other_user = insert(:user) + + {:ok, %Activity{id: activity_id}} = + CommonAPI.post(other_user, %{status: "cofe", visibility: "local"}) + + with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do + assert {:ok, %Activity{data: %{"type" => "Announce"}} = activity} = + CommonAPI.repeat(activity_id, user) + + assert Visibility.is_local_public?(activity) + refute called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "unrepeat" do + user = insert(:user) + other_user = insert(:user) + + {:ok, %Activity{id: activity_id}} = + CommonAPI.post(other_user, %{status: "cofe", visibility: "local"}) + + assert {:ok, _} = CommonAPI.repeat(activity_id, user) + + with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do + assert {:ok, %Activity{data: %{"type" => "Undo"}} = activity} = + CommonAPI.unrepeat(activity_id, user) + + assert Visibility.is_local_public?(activity) + refute called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "favorite" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe", visibility: "local"}) + + with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do + assert {:ok, %Activity{data: %{"type" => "Like"}} = activity} = + CommonAPI.favorite(user, activity.id) + + assert Visibility.is_local_public?(activity) + refute called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "unfavorite" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe", visibility: "local"}) + + {:ok, %Activity{}} = CommonAPI.favorite(user, activity.id) + + with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do + assert {:ok, activity} = CommonAPI.unfavorite(activity.id, user) + assert Visibility.is_local_public?(activity) + refute called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "react_with_emoji" do + user = insert(:user) + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe", visibility: "local"}) + + with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do + assert {:ok, %Activity{data: %{"type" => "EmojiReact"}} = activity} = + CommonAPI.react_with_emoji(activity.id, user, "๐Ÿ‘") + + assert Visibility.is_local_public?(activity) + refute called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "unreact_with_emoji" do + user = insert(:user) + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe", visibility: "local"}) + + {:ok, _reaction} = CommonAPI.react_with_emoji(activity.id, user, "๐Ÿ‘") + + with_mock Pleroma.Web.Federator, publish: fn _ -> :ok end do + assert {:ok, %Activity{data: %{"type" => "Undo"}} = activity} = + CommonAPI.unreact_with_emoji(activity.id, user, "๐Ÿ‘") + + assert Visibility.is_local_public?(activity) + refute called(Pleroma.Web.Federator.publish(activity)) + end + end + end end diff --git a/test/pleroma/web/endpoint/metrics_exporter_test.exs b/test/pleroma/web/endpoint/metrics_exporter_test.exs new file mode 100644 index 000000000..d0cae3d42 --- /dev/null +++ b/test/pleroma/web/endpoint/metrics_exporter_test.exs @@ -0,0 +1,69 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Endpoint.MetricsExporterTest do + # Modifies AppEnv, has to stay synchronous + use Pleroma.Web.ConnCase + + alias Pleroma.Web.Endpoint.MetricsExporter + + defp config do + Application.get_env(:prometheus, MetricsExporter) + end + + describe "with default config" do + test "does NOT expose app metrics", %{conn: conn} do + conn + |> get(config()[:path]) + |> json_response(404) + end + end + + describe "when enabled" do + setup do + initial_config = config() + on_exit(fn -> Application.put_env(:prometheus, MetricsExporter, initial_config) end) + + Application.put_env( + :prometheus, + MetricsExporter, + Keyword.put(initial_config, :enabled, true) + ) + end + + test "serves app metrics", %{conn: conn} do + conn = get(conn, config()[:path]) + assert response = response(conn, 200) + + for metric <- [ + "http_requests_total", + "http_request_duration_microseconds", + "phoenix_controller_call_duration", + "telemetry_scrape_duration", + "erlang_vm_memory_atom_bytes_total" + ] do + assert response =~ ~r/#{metric}/ + end + end + + test "when IP whitelist configured, " <> + "serves app metrics only if client IP is whitelisted", + %{conn: conn} do + Application.put_env( + :prometheus, + MetricsExporter, + Keyword.put(config(), :ip_whitelist, ["127.127.127.127", {1, 1, 1, 1}, '255.255.255.255']) + ) + + conn + |> get(config()[:path]) + |> json_response(404) + + conn + |> Map.put(:remote_ip, {127, 127, 127, 127}) + |> get(config()[:path]) + |> response(200) + end + end +end diff --git a/test/pleroma/web/fallback_test.exs b/test/pleroma/web/fallback_test.exs index a65865860..46c7bad1c 100644 --- a/test/pleroma/web/fallback_test.exs +++ b/test/pleroma/web/fallback_test.exs @@ -20,15 +20,26 @@ test "GET /*path", %{conn: conn} do end end + test "GET /*path adds a title", %{conn: conn} do + clear_config([:instance, :name], "a cool title") + + assert conn + |> get("/") + |> html_response(200) =~ "a cool title" + end + describe "preloaded data and metadata attached to" do test "GET /:maybe_nickname_or_id", %{conn: conn} do + clear_config([:instance, :name], "a cool title") + user = insert(:user) user_missing = get(conn, "/foo") user_present = get(conn, "/#{user.nickname}") - assert(html_response(user_missing, 200) =~ "") + assert html_response(user_missing, 200) =~ "" refute html_response(user_present, 200) =~ "" assert html_response(user_present, 200) =~ "initial-results" + assert html_response(user_present, 200) =~ "a cool title" end test "GET /*path", %{conn: conn} do @@ -44,10 +55,13 @@ test "GET /*path", %{conn: conn} do describe "preloaded data is attached to" do test "GET /main/public", %{conn: conn} do + clear_config([:instance, :name], "a cool title") + public_page = get(conn, "/main/public") refute html_response(public_page, 200) =~ "" assert html_response(public_page, 200) =~ "initial-results" + assert html_response(public_page, 200) =~ "a cool title" end test "GET /main/all", %{conn: conn} do diff --git a/test/pleroma/web/fed_sockets/fed_registry_test.exs b/test/pleroma/web/fed_sockets/fed_registry_test.exs deleted file mode 100644 index 19ac874d6..000000000 --- a/test/pleroma/web/fed_sockets/fed_registry_test.exs +++ /dev/null @@ -1,124 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright ยฉ 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.FedRegistryTest do - use ExUnit.Case - - alias Pleroma.Web.FedSockets - alias Pleroma.Web.FedSockets.FedRegistry - alias Pleroma.Web.FedSockets.SocketInfo - - @good_domain "http://good.domain" - @good_domain_origin "good.domain:80" - - setup do - start_supervised({Pleroma.Web.FedSockets.Supervisor, []}) - build_test_socket(@good_domain) - Process.sleep(10) - - :ok - end - - describe "add_fed_socket/1 without conflicting sockets" do - test "can be added" do - Process.sleep(10) - assert {:ok, %SocketInfo{origin: origin}} = FedRegistry.get_fed_socket(@good_domain_origin) - assert origin == "good.domain:80" - end - - test "multiple origins can be added" do - build_test_socket("http://anothergood.domain") - Process.sleep(10) - - assert {:ok, %SocketInfo{origin: origin_1}} = - FedRegistry.get_fed_socket(@good_domain_origin) - - assert {:ok, %SocketInfo{origin: origin_2}} = - FedRegistry.get_fed_socket("anothergood.domain:80") - - assert origin_1 == "good.domain:80" - assert origin_2 == "anothergood.domain:80" - assert FedRegistry.list_all() |> Enum.count() == 2 - end - end - - describe "add_fed_socket/1 when duplicate sockets conflict" do - setup do - build_test_socket(@good_domain) - build_test_socket(@good_domain) - Process.sleep(10) - :ok - end - - test "will be ignored" do - assert {:ok, %SocketInfo{origin: origin, pid: pid_one}} = - FedRegistry.get_fed_socket(@good_domain_origin) - - assert origin == "good.domain:80" - - assert FedRegistry.list_all() |> Enum.count() == 1 - end - - test "the newer process will be closed" do - pid_two = build_test_socket(@good_domain) - - assert {:ok, %SocketInfo{origin: origin, pid: pid_one}} = - FedRegistry.get_fed_socket(@good_domain_origin) - - assert origin == "good.domain:80" - Process.sleep(10) - - refute Process.alive?(pid_two) - - assert FedRegistry.list_all() |> Enum.count() == 1 - end - end - - describe "get_fed_socket/1" do - test "returns missing for unknown hosts" do - assert {:error, :missing} = FedRegistry.get_fed_socket("not_a_dmoain") - end - - test "returns rejected for hosts previously rejected" do - "rejected.domain:80" - |> FedSockets.uri_for_origin() - |> FedRegistry.set_host_rejected() - - assert {:error, :rejected} = FedRegistry.get_fed_socket("rejected.domain:80") - end - - test "can retrieve a previously added SocketInfo" do - build_test_socket(@good_domain) - Process.sleep(10) - assert {:ok, %SocketInfo{origin: origin}} = FedRegistry.get_fed_socket(@good_domain_origin) - assert origin == "good.domain:80" - end - - test "removes references to SocketInfos when the process crashes" do - assert {:ok, %SocketInfo{origin: origin, pid: pid}} = - FedRegistry.get_fed_socket(@good_domain_origin) - - assert origin == "good.domain:80" - - Process.exit(pid, :testing) - Process.sleep(100) - assert {:error, :missing} = FedRegistry.get_fed_socket(@good_domain_origin) - end - end - - def build_test_socket(uri) do - Kernel.spawn(fn -> fed_socket_almost(uri) end) - end - - def fed_socket_almost(origin) do - FedRegistry.add_fed_socket(origin) - - receive do - :close -> - :ok - after - 5_000 -> :timeout - end - end -end diff --git a/test/pleroma/web/fed_sockets/fetch_registry_test.exs b/test/pleroma/web/fed_sockets/fetch_registry_test.exs deleted file mode 100644 index 7bd2d995a..000000000 --- a/test/pleroma/web/fed_sockets/fetch_registry_test.exs +++ /dev/null @@ -1,67 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright ยฉ 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.FetchRegistryTest do - use ExUnit.Case - - alias Pleroma.Web.FedSockets.FetchRegistry - alias Pleroma.Web.FedSockets.FetchRegistry.FetchRegistryData - - @json_message "hello" - @json_reply "hello back" - - setup do - start_supervised( - {Pleroma.Web.FedSockets.Supervisor, - [ - ping_interval: 8, - connection_duration: 15, - rejection_duration: 5, - fed_socket_fetches: [default: 10, interval: 10] - ]} - ) - - :ok - end - - test "fetches can be stored" do - uuid = FetchRegistry.register_fetch(@json_message) - - assert {:error, :waiting} = FetchRegistry.check_fetch(uuid) - end - - test "fetches can return" do - uuid = FetchRegistry.register_fetch(@json_message) - task = Task.async(fn -> FetchRegistry.register_fetch_received(uuid, @json_reply) end) - - assert {:error, :waiting} = FetchRegistry.check_fetch(uuid) - Task.await(task) - - assert {:ok, %FetchRegistryData{received_json: received_json}} = - FetchRegistry.check_fetch(uuid) - - assert received_json == @json_reply - end - - test "fetches are deleted once popped from stack" do - uuid = FetchRegistry.register_fetch(@json_message) - task = Task.async(fn -> FetchRegistry.register_fetch_received(uuid, @json_reply) end) - Task.await(task) - - assert {:ok, %FetchRegistryData{received_json: received_json}} = - FetchRegistry.check_fetch(uuid) - - assert received_json == @json_reply - assert {:ok, @json_reply} = FetchRegistry.pop_fetch(uuid) - - assert {:error, :missing} = FetchRegistry.check_fetch(uuid) - end - - test "fetches can time out" do - uuid = FetchRegistry.register_fetch(@json_message) - assert {:error, :waiting} = FetchRegistry.check_fetch(uuid) - Process.sleep(500) - assert {:error, :missing} = FetchRegistry.check_fetch(uuid) - end -end diff --git a/test/pleroma/web/fed_sockets/socket_info_test.exs b/test/pleroma/web/fed_sockets/socket_info_test.exs deleted file mode 100644 index db3d6edcd..000000000 --- a/test/pleroma/web/fed_sockets/socket_info_test.exs +++ /dev/null @@ -1,118 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright ยฉ 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.FedSockets.SocketInfoTest do - use ExUnit.Case - - alias Pleroma.Web.FedSockets - alias Pleroma.Web.FedSockets.SocketInfo - - describe "uri_for_origin" do - test "provides the fed_socket URL given the origin information" do - endpoint = "example.com:4000" - assert FedSockets.uri_for_origin(endpoint) =~ "ws://" - assert FedSockets.uri_for_origin(endpoint) =~ endpoint - end - end - - describe "origin" do - test "will provide the origin field given a url" do - endpoint = "example.com:4000" - assert SocketInfo.origin("ws://#{endpoint}") == endpoint - assert SocketInfo.origin("http://#{endpoint}") == endpoint - assert SocketInfo.origin("https://#{endpoint}") == endpoint - end - - test "will proide the origin field given a uri" do - endpoint = "example.com:4000" - uri = URI.parse("http://#{endpoint}") - - assert SocketInfo.origin(uri) == endpoint - end - end - - describe "touch" do - test "will update the TTL" do - endpoint = "example.com:4000" - socket = SocketInfo.build("ws://#{endpoint}") - Process.sleep(2) - touched_socket = SocketInfo.touch(socket) - - assert socket.connected_until < touched_socket.connected_until - end - end - - describe "expired?" do - setup do - start_supervised( - {Pleroma.Web.FedSockets.Supervisor, - [ - ping_interval: 8, - connection_duration: 5, - rejection_duration: 5, - fed_socket_rejections: [lazy: true] - ]} - ) - - :ok - end - - test "tests if the TTL is exceeded" do - endpoint = "example.com:4000" - socket = SocketInfo.build("ws://#{endpoint}") - refute SocketInfo.expired?(socket) - Process.sleep(10) - - assert SocketInfo.expired?(socket) - end - end - - describe "creating outgoing connection records" do - test "can be passed a string" do - assert %{conn_pid: :pid, origin: _origin} = SocketInfo.build("example.com:4000", :pid) - end - - test "can be passed a URI" do - uri = URI.parse("http://example.com:4000") - assert %{conn_pid: :pid, origin: origin} = SocketInfo.build(uri, :pid) - assert origin =~ "example.com:4000" - end - - test "will include the port number" do - assert %{conn_pid: :pid, origin: origin} = SocketInfo.build("http://example.com:4000", :pid) - - assert origin =~ ":4000" - end - - test "will provide the port if missing" do - assert %{conn_pid: :pid, origin: "example.com:80"} = - SocketInfo.build("http://example.com", :pid) - - assert %{conn_pid: :pid, origin: "example.com:443"} = - SocketInfo.build("https://example.com", :pid) - end - end - - describe "creating incoming connection records" do - test "can be passed a string" do - assert %{pid: _, origin: _origin} = SocketInfo.build("example.com:4000") - end - - test "can be passed a URI" do - uri = URI.parse("example.com:4000") - assert %{pid: _, origin: _origin} = SocketInfo.build(uri) - end - - test "will include the port number" do - assert %{pid: _, origin: origin} = SocketInfo.build("http://example.com:4000") - - assert origin =~ ":4000" - end - - test "will provide the port if missing" do - assert %{pid: _, origin: "example.com:80"} = SocketInfo.build("http://example.com") - assert %{pid: _, origin: "example.com:443"} = SocketInfo.build("https://example.com") - end - end -end diff --git a/test/pleroma/web/federator_test.exs b/test/pleroma/web/federator_test.exs index 592fdccd1..67001add7 100644 --- a/test/pleroma/web/federator_test.exs +++ b/test/pleroma/web/federator_test.exs @@ -164,7 +164,7 @@ test "it does not crash if MRF rejects the post" do params = File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() assert {:ok, job} = Federator.incoming_ap_doc(params) assert {:error, _} = ObanHelpers.perform(job) diff --git a/test/pleroma/web/feed/tag_controller_test.exs b/test/pleroma/web/feed/tag_controller_test.exs index 868e40965..b4abcf6f2 100644 --- a/test/pleroma/web/feed/tag_controller_test.exs +++ b/test/pleroma/web/feed/tag_controller_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.Feed.TagControllerTest do import Pleroma.Factory import SweetXml + alias Pleroma.Config alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.Feed.FeedView @@ -15,7 +16,7 @@ defmodule Pleroma.Web.Feed.TagControllerTest do setup do: clear_config([:feed]) test "gets a feed (ATOM)", %{conn: conn} do - Pleroma.Config.put( + Config.put( [:feed, :post_title], %{max_length: 25, omission: "..."} ) @@ -82,7 +83,7 @@ test "gets a feed (ATOM)", %{conn: conn} do end test "gets a feed (RSS)", %{conn: conn} do - Pleroma.Config.put( + Config.put( [:feed, :post_title], %{max_length: 25, omission: "..."} ) @@ -130,7 +131,7 @@ test "gets a feed (RSS)", %{conn: conn} do '#{Pleroma.Web.base_url()}/tags/pleromaart.rss' assert xpath(xml, ~x"//channel/webfeeds:logo/text()") == - '#{Pleroma.Web.base_url()}/static/logo.png' + '#{Pleroma.Web.base_url()}/static/logo.svg' assert xpath(xml, ~x"//channel/item/title/text()"l) == [ '42 This is :moominmamm...', @@ -157,7 +158,7 @@ test "gets a feed (RSS)", %{conn: conn} do response = conn |> put_req_header("accept", "application/rss+xml") - |> get(tag_feed_path(conn, :feed, "pleromaart")) + |> get(tag_feed_path(conn, :feed, "pleromaart.rss")) |> response(200) xml = parse(response) @@ -183,14 +184,12 @@ test "gets a feed (RSS)", %{conn: conn} do end describe "private instance" do - setup do: clear_config([:instance, :public]) + setup do: clear_config([:instance, :public], false) test "returns 404 for tags feed", %{conn: conn} do - Config.put([:instance, :public], false) - conn |> put_req_header("accept", "application/rss+xml") - |> get(tag_feed_path(conn, :feed, "pleromaart")) + |> get(tag_feed_path(conn, :feed, "pleromaart.rss")) |> response(404) end end diff --git a/test/pleroma/web/feed/user_controller_test.exs b/test/pleroma/web/feed/user_controller_test.exs index a5dc0894b..16f002717 100644 --- a/test/pleroma/web/feed/user_controller_test.exs +++ b/test/pleroma/web/feed/user_controller_test.exs @@ -12,16 +12,17 @@ defmodule Pleroma.Web.Feed.UserControllerTest do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Feed.FeedView - setup do: clear_config([:instance, :federating], true) + setup do: clear_config([:static_fe, :enabled], false) describe "feed" do setup do: clear_config([:feed]) - test "gets an atom feed", %{conn: conn} do + setup do Config.put( [:feed, :post_title], - %{max_length: 10, omission: "..."} + %{max_length: 15, omission: "..."} ) activity = insert(:note_activity) @@ -29,7 +30,8 @@ test "gets an atom feed", %{conn: conn} do note = insert(:note, data: %{ - "content" => "This is :moominmamma: note ", + "content" => "This & this is :moominmamma: note ", + "source" => "This & this is :moominmamma: note ", "attachment" => [ %{ "url" => [ @@ -37,7 +39,9 @@ test "gets an atom feed", %{conn: conn} do ] } ], - "inReplyTo" => activity.data["id"] + "inReplyTo" => activity.data["id"], + "context" => "2hu & as", + "summary" => "2hu & as" } ) @@ -48,7 +52,7 @@ test "gets an atom feed", %{conn: conn} do insert(:note, user: user, data: %{ - "content" => "42 This is :moominmamma: note ", + "content" => "42 & This is :moominmamma: note ", "inReplyTo" => activity.data["id"] } ) @@ -56,6 +60,10 @@ test "gets an atom feed", %{conn: conn} do note_activity2 = insert(:note_activity, note: note2) object = Object.normalize(note_activity) + [user: user, object: object, max_id: note_activity2.id] + end + + test "gets an atom feed", %{conn: conn, user: user, object: object, max_id: max_id} do resp = conn |> put_req_header("accept", "application/atom+xml") @@ -67,13 +75,15 @@ test "gets an atom feed", %{conn: conn} do |> SweetXml.parse() |> SweetXml.xpath(~x"//entry/title/text()"l) - assert activity_titles == ['42 This...', 'This is...'] - assert resp =~ object.data["content"] + assert activity_titles == ['42 & Thi...', 'This & t...'] + assert resp =~ FeedView.escape(object.data["content"]) + assert resp =~ FeedView.escape(object.data["summary"]) + assert resp =~ FeedView.escape(object.data["context"]) resp = conn |> put_req_header("accept", "application/atom+xml") - |> get("/users/#{user.nickname}/feed", %{"max_id" => note_activity2.id}) + |> get("/users/#{user.nickname}/feed", %{"max_id" => max_id}) |> response(200) activity_titles = @@ -81,47 +91,10 @@ test "gets an atom feed", %{conn: conn} do |> SweetXml.parse() |> SweetXml.xpath(~x"//entry/title/text()"l) - assert activity_titles == ['This is...'] + assert activity_titles == ['This & t...'] end - test "gets a rss feed", %{conn: conn} do - Pleroma.Config.put( - [:feed, :post_title], - %{max_length: 10, omission: "..."} - ) - - activity = insert(:note_activity) - - note = - insert(:note, - data: %{ - "content" => "This is :moominmamma: note ", - "attachment" => [ - %{ - "url" => [ - %{"mediaType" => "image/png", "href" => "https://pleroma.gov/image.png"} - ] - } - ], - "inReplyTo" => activity.data["id"] - } - ) - - note_activity = insert(:note_activity, note: note) - user = User.get_cached_by_ap_id(note_activity.data["actor"]) - - note2 = - insert(:note, - user: user, - data: %{ - "content" => "42 This is :moominmamma: note ", - "inReplyTo" => activity.data["id"] - } - ) - - note_activity2 = insert(:note_activity, note: note2) - object = Object.normalize(note_activity) - + test "gets a rss feed", %{conn: conn, user: user, object: object, max_id: max_id} do resp = conn |> put_req_header("accept", "application/rss+xml") @@ -133,13 +106,15 @@ test "gets a rss feed", %{conn: conn} do |> SweetXml.parse() |> SweetXml.xpath(~x"//item/title/text()"l) - assert activity_titles == ['42 This...', 'This is...'] - assert resp =~ object.data["content"] + assert activity_titles == ['42 & Thi...', 'This & t...'] + assert resp =~ FeedView.escape(object.data["content"]) + assert resp =~ FeedView.escape(object.data["summary"]) + assert resp =~ FeedView.escape(object.data["context"]) resp = conn |> put_req_header("accept", "application/rss+xml") - |> get("/users/#{user.nickname}/feed.rss", %{"max_id" => note_activity2.id}) + |> get("/users/#{user.nickname}/feed.rss", %{"max_id" => max_id}) |> response(200) activity_titles = @@ -147,7 +122,7 @@ test "gets a rss feed", %{conn: conn} do |> SweetXml.parse() |> SweetXml.xpath(~x"//item/title/text()"l) - assert activity_titles == ['This is...'] + assert activity_titles == ['This & t...'] end test "returns 404 for a missing feed", %{conn: conn} do @@ -192,6 +167,16 @@ test "returns 404 when the user is remote", %{conn: conn} do |> get(user_feed_path(conn, :feed, user.nickname)) |> response(404) end + + test "does not require authentication on non-federating instances", %{conn: conn} do + clear_config([:instance, :federating], false) + user = insert(:user) + + conn + |> put_req_header("accept", "application/rss+xml") + |> get("/users/#{user.nickname}/feed.rss") + |> response(200) + end end # Note: see ActivityPubControllerTest for JSON format tests diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index 7336fa8de..f6285853a 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -32,7 +32,7 @@ test "works by id" do test "works by nickname" do user = insert(:user) - assert %{"id" => user_id} = + assert %{"id" => _user_id} = build_conn() |> get("/api/v1/accounts/#{user.nickname}") |> json_response_and_validate_schema(200) @@ -43,7 +43,7 @@ test "works by nickname for remote users" do user = insert(:user, nickname: "user@example.com", local: false) - assert %{"id" => user_id} = + assert %{"id" => _user_id} = build_conn() |> get("/api/v1/accounts/#{user.nickname}") |> json_response_and_validate_schema(200) @@ -320,7 +320,7 @@ test "gets users statuses", %{conn: conn} do user_two = insert(:user) user_three = insert(:user) - {:ok, _user_three} = User.follow(user_three, user_one) + {:ok, _user_three, _user_one} = User.follow(user_three, user_one) {:ok, activity} = CommonAPI.post(user_one, %{status: "HI!!!"}) @@ -436,6 +436,39 @@ test "the user views their own timelines and excludes direct messages", %{ conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_visibilities[]=direct") assert [%{"id" => ^public_activity_id}] = json_response_and_validate_schema(conn, 200) end + + test "muted reactions", %{user: user, conn: conn} do + user2 = insert(:user) + User.mute(user, user2) + {:ok, activity} = CommonAPI.post(user, %{status: "."}) + {:ok, _} = CommonAPI.react_with_emoji(activity.id, user2, "๐ŸŽ…") + + result = + conn + |> get("/api/v1/accounts/#{user.id}/statuses") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [] + } + } + ] = result + + result = + conn + |> get("/api/v1/accounts/#{user.id}/statuses?with_muted=true") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "๐ŸŽ…"}] + } + } + ] = result + end end defp local_and_remote_activities(%{local: local, remote: remote}) do @@ -535,7 +568,7 @@ test "if user is authenticated", %{local: local, remote: remote} do test "getting followers", %{user: user, conn: conn} do other_user = insert(:user) - {:ok, %{id: user_id}} = User.follow(user, other_user) + {:ok, %{id: user_id}, other_user} = User.follow(user, other_user) conn = get(conn, "/api/v1/accounts/#{other_user.id}/followers") @@ -544,7 +577,7 @@ test "getting followers", %{user: user, conn: conn} do test "getting followers, hide_followers", %{user: user, conn: conn} do other_user = insert(:user, hide_followers: true) - {:ok, _user} = User.follow(user, other_user) + {:ok, _user, _other_user} = User.follow(user, other_user) conn = get(conn, "/api/v1/accounts/#{other_user.id}/followers") @@ -554,7 +587,7 @@ test "getting followers, hide_followers", %{user: user, conn: conn} do test "getting followers, hide_followers, same user requesting" do user = insert(:user) other_user = insert(:user, hide_followers: true) - {:ok, _user} = User.follow(user, other_user) + {:ok, _user, _other_user} = User.follow(user, other_user) conn = build_conn() @@ -566,9 +599,9 @@ test "getting followers, hide_followers, same user requesting" do end test "getting followers, pagination", %{user: user, conn: conn} do - {:ok, %User{id: follower1_id}} = :user |> insert() |> User.follow(user) - {:ok, %User{id: follower2_id}} = :user |> insert() |> User.follow(user) - {:ok, %User{id: follower3_id}} = :user |> insert() |> User.follow(user) + {:ok, %User{id: follower1_id}, _user} = :user |> insert() |> User.follow(user) + {:ok, %User{id: follower2_id}, _user} = :user |> insert() |> User.follow(user) + {:ok, %User{id: follower3_id}, _user} = :user |> insert() |> User.follow(user) assert [%{"id" => ^follower3_id}, %{"id" => ^follower2_id}] = conn @@ -604,7 +637,7 @@ test "getting followers, pagination", %{user: user, conn: conn} do test "getting following", %{user: user, conn: conn} do other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) + {:ok, user, other_user} = User.follow(user, other_user) conn = get(conn, "/api/v1/accounts/#{user.id}/following") @@ -615,7 +648,7 @@ test "getting following", %{user: user, conn: conn} do test "getting following, hide_follows, other user requesting" do user = insert(:user, hide_follows: true) other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) + {:ok, user, other_user} = User.follow(user, other_user) conn = build_conn() @@ -629,7 +662,7 @@ test "getting following, hide_follows, other user requesting" do test "getting following, hide_follows, same user requesting" do user = insert(:user, hide_follows: true) other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) + {:ok, user, _other_user} = User.follow(user, other_user) conn = build_conn() @@ -644,9 +677,9 @@ test "getting following, pagination", %{user: user, conn: conn} do following1 = insert(:user) following2 = insert(:user) following3 = insert(:user) - {:ok, _} = User.follow(user, following1) - {:ok, _} = User.follow(user, following2) - {:ok, _} = User.follow(user, following3) + {:ok, _, _} = User.follow(user, following1) + {:ok, _, _} = User.follow(user, following2) + {:ok, _, _} = User.follow(user, following3) res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?since_id=#{following1.id}") @@ -1378,8 +1411,6 @@ test "creates an account and returns 200 if captcha is valid", %{conn: conn} do |> json_response_and_validate_schema(:ok) assert Token |> Repo.get_by(token: access_token) |> Repo.preload(:user) |> Map.get(:user) - - Cachex.del(:used_captcha_cache, token) end test "returns 400 if any captcha field is not provided", %{conn: conn} do @@ -1429,10 +1460,10 @@ test "returns an error if captcha is invalid", %{conn: conn} do test "returns lists to which the account belongs" do %{user: user, conn: conn} = oauth_access(["read:lists"]) other_user = insert(:user) - assert {:ok, %Pleroma.List{id: list_id} = list} = Pleroma.List.create("Test List", user) + assert {:ok, %Pleroma.List{id: _list_id} = list} = Pleroma.List.create("Test List", user) {:ok, %{following: _following}} = Pleroma.List.follow(list, other_user) - assert [%{"id" => list_id, "title" => "Test List"}] = + assert [%{"id" => _list_id, "title" => "Test List"}] = conn |> get("/api/v1/accounts/#{other_user.id}/lists") |> json_response_and_validate_schema(200) @@ -1487,7 +1518,7 @@ test "locked accounts" do test "returns the relationships for the current user", %{user: user, conn: conn} do %{id: other_user_id} = other_user = insert(:user) - {:ok, _user} = User.follow(user, other_user) + {:ok, _user, _other_user} = User.follow(user, other_user) assert [%{"id" => ^other_user_id}] = conn @@ -1509,28 +1540,103 @@ test "returns an empty list on a bad request", %{conn: conn} do test "getting a list of mutes" do %{user: user, conn: conn} = oauth_access(["read:mutes"]) - other_user = insert(:user) + %{id: id1} = other_user1 = insert(:user) + %{id: id2} = other_user2 = insert(:user) + %{id: id3} = other_user3 = insert(:user) - {:ok, _user_relationships} = User.mute(user, other_user) + {:ok, _user_relationships} = User.mute(user, other_user1) + {:ok, _user_relationships} = User.mute(user, other_user2) + {:ok, _user_relationships} = User.mute(user, other_user3) - conn = get(conn, "/api/v1/mutes") + result = + conn + |> assign(:user, user) + |> get("/api/v1/mutes") + |> json_response_and_validate_schema(200) - other_user_id = to_string(other_user.id) - assert [%{"id" => ^other_user_id}] = json_response_and_validate_schema(conn, 200) + assert [id1, id2, id3] == Enum.map(result, & &1["id"]) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/mutes?limit=1") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id1}] = result + + result = + conn + |> assign(:user, user) + |> get("/api/v1/mutes?since_id=#{id1}") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id2}, %{"id" => ^id3}] = result + + result = + conn + |> assign(:user, user) + |> get("/api/v1/mutes?since_id=#{id1}&max_id=#{id3}") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id2}] = result + + result = + conn + |> assign(:user, user) + |> get("/api/v1/mutes?since_id=#{id1}&limit=1") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id2}] = result end test "getting a list of blocks" do %{user: user, conn: conn} = oauth_access(["read:blocks"]) - other_user = insert(:user) + %{id: id1} = other_user1 = insert(:user) + %{id: id2} = other_user2 = insert(:user) + %{id: id3} = other_user3 = insert(:user) - {:ok, _user_relationship} = User.block(user, other_user) + {:ok, _user_relationship} = User.block(user, other_user1) + {:ok, _user_relationship} = User.block(user, other_user3) + {:ok, _user_relationship} = User.block(user, other_user2) - conn = + result = conn |> assign(:user, user) |> get("/api/v1/blocks") + |> json_response_and_validate_schema(200) - other_user_id = to_string(other_user.id) - assert [%{"id" => ^other_user_id}] = json_response_and_validate_schema(conn, 200) + assert [id1, id2, id3] == Enum.map(result, & &1["id"]) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/blocks?limit=1") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id1}] = result + + result = + conn + |> assign(:user, user) + |> get("/api/v1/blocks?since_id=#{id1}") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id2}, %{"id" => ^id3}] = result + + result = + conn + |> assign(:user, user) + |> get("/api/v1/blocks?since_id=#{id1}&max_id=#{id3}") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id2}] = result + + result = + conn + |> assign(:user, user) + |> get("/api/v1/blocks?since_id=#{id1}&limit=1") + |> json_response_and_validate_schema(200) + + assert [%{"id" => ^id2}] = result end end diff --git a/test/pleroma/web/mastodon_api/controllers/auth_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/auth_controller_test.exs index bf2438fe2..ce957054b 100644 --- a/test/pleroma/web/mastodon_api/controllers/auth_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/auth_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.AuthControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Config alias Pleroma.Repo @@ -39,7 +39,7 @@ test "redirects to the saved path after log in", %{conn: conn, path: path} do |> get("/web/login", %{code: auth.token}) assert conn.status == 302 - assert redirected_to(conn) == path + assert redirected_to(conn) =~ path end test "redirects to the getting-started page when referer is not present", %{conn: conn} do @@ -49,7 +49,7 @@ test "redirects to the getting-started page when referer is not present", %{conn conn = get(conn, "/web/login", %{code: auth.token}) assert conn.status == 302 - assert redirected_to(conn) == "/web/getting-started" + assert redirected_to(conn) =~ "/web/getting-started" end end diff --git a/test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs index 3e21e6bf1..a03513e06 100644 --- a/test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs @@ -3,8 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true + alias Pleroma.Conversation.Participation alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -17,7 +18,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do user_two = insert(:user) user_three = insert(:user) - {:ok, user_two} = User.follow(user_two, user_one) + {:ok, user_two, user_one} = User.follow(user_two, user_one) {:ok, %{user: user_one, user_two: user_two, user_three: user_three, conn: conn}} end @@ -28,10 +29,10 @@ test "returns correct conversations", %{ user_three: user_three, conn: conn } do - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 + assert Participation.unread_count(user_two) == 0 {:ok, direct} = create_direct_message(user_one, [user_two, user_three]) - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 + assert Participation.unread_count(user_two) == 1 {:ok, _follower_only} = CommonAPI.post(user_one, %{ @@ -54,12 +55,33 @@ test "returns correct conversations", %{ account_ids = Enum.map(res_accounts, & &1["id"]) assert length(res_accounts) == 2 + assert user_one.id not in account_ids assert user_two.id in account_ids assert user_three.id in account_ids assert is_binary(res_id) assert unread == false assert res_last_status["id"] == direct.id - assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 + assert res_last_status["account"]["id"] == user_one.id + assert Participation.unread_count(user_one) == 0 + end + + test "includes the user if the user is the only participant", %{ + user: user_one, + conn: conn + } do + {:ok, _direct} = create_direct_message(user_one, []) + + res_conn = get(conn, "/api/v1/conversations") + + assert response = json_response_and_validate_schema(res_conn, 200) + + assert [ + %{ + "accounts" => [account] + } + ] = response + + assert user_one.id == account["id"] end test "observes limit params", %{ @@ -134,8 +156,8 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do user_two = insert(:user) {:ok, direct} = create_direct_message(user_one, [user_two]) - assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 + assert Participation.unread_count(user_one) == 0 + assert Participation.unread_count(user_two) == 1 user_two_conn = build_conn() @@ -155,8 +177,8 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do |> post("/api/v1/conversations/#{direct_conversation_id}/read") |> json_response_and_validate_schema(200) - assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 + assert Participation.unread_count(user_one) == 0 + assert Participation.unread_count(user_two) == 0 # The conversation is marked as unread on reply {:ok, _} = @@ -171,8 +193,8 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do |> get("/api/v1/conversations") |> json_response_and_validate_schema(200) - assert User.get_cached_by_id(user_one.id).unread_conversation_count == 1 - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 + assert Participation.unread_count(user_one) == 1 + assert Participation.unread_count(user_two) == 0 # A reply doesn't increment the user's unread_conversation_count if the conversation is unread {:ok, _} = @@ -182,8 +204,8 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do in_reply_to_status_id: direct.id }) - assert User.get_cached_by_id(user_one.id).unread_conversation_count == 1 - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 + assert Participation.unread_count(user_one) == 1 + assert Participation.unread_count(user_two) == 0 end test "(vanilla) Mastodon frontend behaviour", %{user: user_one, conn: conn} do diff --git a/test/pleroma/web/mastodon_api/controllers/domain_block_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/domain_block_controller_test.exs index 664654500..b10aa6966 100644 --- a/test/pleroma/web/mastodon_api/controllers/domain_block_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/domain_block_controller_test.exs @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.DomainBlockControllerTest do + # TODO: Should not need Cachex use Pleroma.Web.ConnCase alias Pleroma.User diff --git a/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs index 0d426ec34..e639cdde1 100644 --- a/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Web.MastodonAPI.FilterView diff --git a/test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs index a9dd7cd30..f0a466212 100644 --- a/test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/follow_request_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -21,7 +21,7 @@ test "/api/v1/follow_requests works", %{user: user, conn: conn} do other_user = insert(:user) {:ok, _, _, _activity} = CommonAPI.follow(other_user, user) - {:ok, other_user} = User.follow(other_user, user, :follow_pending) + {:ok, other_user, user} = User.follow(other_user, user, :follow_pending) assert User.following?(other_user, user) == false @@ -35,7 +35,7 @@ test "/api/v1/follow_requests/:id/authorize works", %{user: user, conn: conn} do other_user = insert(:user) {:ok, _, _, _activity} = CommonAPI.follow(other_user, user) - {:ok, other_user} = User.follow(other_user, user, :follow_pending) + {:ok, other_user, user} = User.follow(other_user, user, :follow_pending) user = User.get_cached_by_id(user.id) other_user = User.get_cached_by_id(other_user.id) diff --git a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs index 6a9ccd979..71a170240 100644 --- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do + # TODO: Should not need Cachex use Pleroma.Web.ConnCase alias Pleroma.User @@ -13,6 +14,9 @@ test "get instance information", %{conn: conn} do assert result = json_response_and_validate_schema(conn, 200) email = Pleroma.Config.get([:instance, :email]) + thumbnail = Pleroma.Web.base_url() <> Pleroma.Config.get([:instance, :instance_thumbnail]) + background = Pleroma.Web.base_url() <> Pleroma.Config.get([:instance, :background_image]) + # Note: not checking for "max_toot_chars" since it's optional assert %{ "uri" => _, @@ -24,7 +28,7 @@ test "get instance information", %{conn: conn} do "streaming_api" => _ }, "stats" => _, - "thumbnail" => _, + "thumbnail" => from_config_thumbnail, "languages" => _, "registrations" => _, "approval_required" => _, @@ -33,7 +37,7 @@ test "get instance information", %{conn: conn} do "avatar_upload_limit" => _, "background_upload_limit" => _, "banner_upload_limit" => _, - "background_image" => _, + "background_image" => from_config_background, "chat_limit" => _, "description_limit" => _ } = result @@ -45,6 +49,8 @@ test "get instance information", %{conn: conn} do assert result["pleroma"]["vapid_public_key"] assert email == from_config_email + assert thumbnail == from_config_thumbnail + assert background == from_config_background end test "get instance stats", %{conn: conn} do diff --git a/test/pleroma/web/mastodon_api/controllers/list_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/list_controller_test.exs index 091ec006c..01f64cfcc 100644 --- a/test/pleroma/web/mastodon_api/controllers/list_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/list_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ListControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Repo diff --git a/test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs index 9f0481120..ee944a67c 100644 --- a/test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory diff --git a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs index 70ef0e8b5..9ac8488f6 100644 --- a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs @@ -75,6 +75,34 @@ test "by default, does not contain pleroma:chat_mention" do assert [_] = result end + test "by default, does not contain pleroma:report" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user = insert(:user) + third_user = insert(:user) + + user + |> User.admin_api_update(%{is_moderator: true}) + + {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"}) + + {:ok, _report} = + CommonAPI.report(third_user, %{account_id: other_user.id, status_ids: [activity.id]}) + + result = + conn + |> get("/api/v1/notifications") + |> json_response_and_validate_schema(200) + + assert [] == result + + result = + conn + |> get("/api/v1/notifications?include_types[]=pleroma:report") + |> json_response_and_validate_schema(200) + + assert [_] = result + end + test "getting a single notification" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) @@ -502,7 +530,7 @@ test "see notifications after muting user without notifications" do assert length(json_response_and_validate_schema(ret_conn, 200)) == 1 - {:ok, _user_relationships} = User.mute(user, user2, false) + {:ok, _user_relationships} = User.mute(user, user2, %{notifications: false}) conn = get(conn, "/api/v1/notifications") diff --git a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs index f41de6448..95e27623d 100644 --- a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.PollControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Object alias Pleroma.Web.CommonAPI diff --git a/test/pleroma/web/mastodon_api/controllers/report_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/report_controller_test.exs index 6636cff96..322eb475c 100644 --- a/test/pleroma/web/mastodon_api/controllers/report_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/report_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Web.CommonAPI diff --git a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs index 04dc6f445..1045ab265 100644 --- a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs @@ -279,6 +279,10 @@ test "search", %{conn: conn} do end test "search fetches remote statuses and prefers them over other results", %{conn: conn} do + old_version = :persistent_term.get({Pleroma.Repo, :postgres_version}) + :persistent_term.put({Pleroma.Repo, :postgres_version}, 10.0) + on_exit(fn -> :persistent_term.put({Pleroma.Repo, :postgres_version}, old_version) end) + capture_log(fn -> {:ok, %{id: activity_id}} = CommonAPI.post(insert(:user), %{ diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 61359214a..de542e5df 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -67,10 +67,6 @@ test "posting a status", %{conn: conn} do "sensitive" => "0" }) - {:ok, ttl} = Cachex.ttl(:idempotency_cache, idempotency_key) - # Six hours - assert ttl > :timer.seconds(6 * 60 * 60 - 1) - assert %{"content" => "cofe", "id" => id, "spoiler_text" => "2hu", "sensitive" => false} = json_response_and_validate_schema(conn_one, 200) @@ -328,7 +324,7 @@ test "fake statuses' preview card is not cached", %{conn: conn} do end test "posting a status with OGP link preview", %{conn: conn} do - Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) clear_config([:rich_media, :enabled], true) conn = @@ -937,7 +933,7 @@ test "reblogged status for another user" do |> get("/api/v1/statuses/#{reblog_activity1.id}") assert %{ - "reblog" => %{"id" => id, "reblogged" => false, "reblogs_count" => 2}, + "reblog" => %{"id" => _id, "reblogged" => false, "reblogs_count" => 2}, "reblogged" => false, "favourited" => false, "bookmarked" => false @@ -1197,7 +1193,7 @@ test "on pin removes deletion job, on unpin reschedule deletion" do end test "returns rich-media card", %{conn: conn, user: user} do - Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) {:ok, activity} = CommonAPI.post(user, %{status: "https://example.com/ogp"}) @@ -1242,7 +1238,7 @@ test "returns rich-media card", %{conn: conn, user: user} do end test "replaces missing description with an empty string", %{conn: conn, user: user} do - Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) {:ok, activity} = CommonAPI.post(user, %{status: "https://example.com/ogp-missing-data"}) @@ -1740,4 +1736,94 @@ test "expires_at is nil for another user" do |> get("/api/v1/statuses/#{activity.id}") |> json_response_and_validate_schema(:ok) end + + test "posting a local only status" do + %{user: _user, conn: conn} = oauth_access(["write:statuses"]) + + conn_one = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "cofe", + "visibility" => "local" + }) + + local = Pleroma.Constants.as_local_public() + + assert %{"content" => "cofe", "id" => id, "visibility" => "local"} = + json_response(conn_one, 200) + + assert %Activity{id: ^id, data: %{"to" => [^local]}} = Activity.get_by_id(id) + end + + describe "muted reactions" do + test "index" do + %{conn: conn, user: user} = oauth_access(["read:statuses"]) + + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "test"}) + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "๐ŸŽ…") + User.mute(user, other_user) + + result = + conn + |> get("/api/v1/statuses/?ids[]=#{activity.id}") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [] + } + } + ] = result + + result = + conn + |> get("/api/v1/statuses/?ids[]=#{activity.id}&with_muted=true") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "๐ŸŽ…"}] + } + } + ] = result + end + + test "show" do + # %{conn: conn, user: user, token: token} = oauth_access(["read:statuses"]) + %{conn: conn, user: user, token: _token} = oauth_access(["read:statuses"]) + + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "test"}) + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "๐ŸŽ…") + User.mute(user, other_user) + + result = + conn + |> get("/api/v1/statuses/#{activity.id}") + |> json_response_and_validate_schema(200) + + assert %{ + "pleroma" => %{ + "emoji_reactions" => [] + } + } = result + + result = + conn + |> get("/api/v1/statuses/#{activity.id}?with_muted=true") + |> json_response_and_validate_schema(200) + + assert %{ + "pleroma" => %{ + "emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "๐ŸŽ…"}] + } + } = result + end + end end diff --git a/test/pleroma/web/mastodon_api/controllers/subscription_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/subscription_controller_test.exs index d36bb1ae8..4bb085750 100644 --- a/test/pleroma/web/mastodon_api/controllers/subscription_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/subscription_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory @@ -45,21 +45,77 @@ defmacro assert_error_when_disable_push(do: yield) do end end - describe "creates push subscription" do - test "returns error when push disabled ", %{conn: conn} do + describe "when disabled" do + test "POST returns error", %{conn: conn} do assert_error_when_disable_push do conn - |> post("/api/v1/push/subscription", %{subscription: @sub}) + |> post("/api/v1/push/subscription", %{ + "data" => %{"alerts" => %{"mention" => true}}, + "subscription" => @sub + }) |> json_response_and_validate_schema(403) end end + test "GET returns error", %{conn: conn} do + assert_error_when_disable_push do + conn + |> get("/api/v1/push/subscription", %{}) + |> json_response_and_validate_schema(403) + end + end + + test "PUT returns error", %{conn: conn} do + assert_error_when_disable_push do + conn + |> put("/api/v1/push/subscription", %{data: %{"alerts" => %{"mention" => false}}}) + |> json_response_and_validate_schema(403) + end + end + + test "DELETE returns error", %{conn: conn} do + assert_error_when_disable_push do + conn + |> delete("/api/v1/push/subscription", %{}) + |> json_response_and_validate_schema(403) + end + end + end + + describe "creates push subscription" do + test "ignores unsupported types", %{conn: conn} do + result = + conn + |> post("/api/v1/push/subscription", %{ + "data" => %{ + "alerts" => %{ + "fake_unsupported_type" => true + } + }, + "subscription" => @sub + }) + |> json_response_and_validate_schema(200) + + refute %{ + "alerts" => %{ + "fake_unsupported_type" => true + } + } == result + end + test "successful creation", %{conn: conn} do result = conn |> post("/api/v1/push/subscription", %{ "data" => %{ - "alerts" => %{"mention" => true, "test" => true, "pleroma:chat_mention" => true} + "alerts" => %{ + "mention" => true, + "favourite" => true, + "follow" => true, + "reblog" => true, + "pleroma:chat_mention" => true, + "pleroma:emoji_reaction" => true + } }, "subscription" => @sub }) @@ -68,7 +124,14 @@ test "successful creation", %{conn: conn} do [subscription] = Pleroma.Repo.all(Subscription) assert %{ - "alerts" => %{"mention" => true, "pleroma:chat_mention" => true}, + "alerts" => %{ + "mention" => true, + "favourite" => true, + "follow" => true, + "reblog" => true, + "pleroma:chat_mention" => true, + "pleroma:emoji_reaction" => true + }, "endpoint" => subscription.endpoint, "id" => to_string(subscription.id), "server_key" => @server_key @@ -77,14 +140,6 @@ test "successful creation", %{conn: conn} do end describe "gets a user subscription" do - test "returns error when push disabled ", %{conn: conn} do - assert_error_when_disable_push do - conn - |> get("/api/v1/push/subscription", %{}) - |> json_response_and_validate_schema(403) - end - end - test "returns error when user hasn't subscription", %{conn: conn} do res = conn @@ -124,30 +179,47 @@ test "returns a user subsciption", %{conn: conn, user: user, token: token} do insert(:push_subscription, user: user, token: token, - data: %{"alerts" => %{"mention" => true}} + data: %{ + "alerts" => %{ + "mention" => true, + "favourite" => true, + "follow" => true, + "reblog" => true, + "pleroma:chat_mention" => true, + "pleroma:emoji_reaction" => true + } + } ) %{conn: conn, user: user, token: token, subscription: subscription} end - test "returns error when push disabled ", %{conn: conn} do - assert_error_when_disable_push do - conn - |> put("/api/v1/push/subscription", %{data: %{"alerts" => %{"mention" => false}}}) - |> json_response_and_validate_schema(403) - end - end - test "returns updated subsciption", %{conn: conn, subscription: subscription} do res = conn |> put("/api/v1/push/subscription", %{ - data: %{"alerts" => %{"mention" => false, "follow" => true}} + data: %{ + "alerts" => %{ + "mention" => false, + "favourite" => false, + "follow" => false, + "reblog" => false, + "pleroma:chat_mention" => false, + "pleroma:emoji_reaction" => false + } + } }) |> json_response_and_validate_schema(200) expect = %{ - "alerts" => %{"follow" => true, "mention" => false}, + "alerts" => %{ + "mention" => false, + "favourite" => false, + "follow" => false, + "reblog" => false, + "pleroma:chat_mention" => false, + "pleroma:emoji_reaction" => false + }, "endpoint" => "https://example.com/example/1234", "id" => to_string(subscription.id), "server_key" => @server_key @@ -158,14 +230,6 @@ test "returns updated subsciption", %{conn: conn, subscription: subscription} do end describe "deletes the user subscription" do - test "returns error when push disabled ", %{conn: conn} do - assert_error_when_disable_push do - conn - |> delete("/api/v1/push/subscription", %{}) - |> json_response_and_validate_schema(403) - end - end - test "returns error when user hasn't subscription", %{conn: conn} do res = conn diff --git a/test/pleroma/web/mastodon_api/controllers/suggestion_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/suggestion_controller_test.exs index 7f08e187c..c3471266a 100644 --- a/test/pleroma/web/mastodon_api/controllers/suggestion_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/suggestion_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.SuggestionControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true setup do: oauth_access(["read"]) diff --git a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs index c6e0268fd..655e35ac6 100644 --- a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs @@ -8,7 +8,6 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do import Pleroma.Factory import Tesla.Mock - alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -55,6 +54,42 @@ test "the home timeline when the direct messages are excluded", %{user: user, co assert private_activity.id in status_ids refute direct_activity.id in status_ids end + + test "muted emotions", %{user: user, conn: conn} do + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "."}) + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "๐ŸŽ…") + User.mute(user, other_user) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/timelines/home") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [] + } + } + ] = result + + result = + conn + |> assign(:user, user) + |> get("/api/v1/timelines/home?with_muted=true") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "๐ŸŽ…"}] + } + } + ] = result + end end describe "public" do @@ -101,7 +136,7 @@ test "the public timeline includes only public statuses for an authenticated use test "doesn't return replies if follower is posting with blocked user" do %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) [blockee, friend] = insert_list(2, :user) - {:ok, blocker} = User.follow(blocker, friend) + {:ok, blocker, friend} = User.follow(blocker, friend) {:ok, _} = User.block(blocker, blockee) conn = assign(conn, :user, blocker) @@ -130,7 +165,7 @@ test "doesn't return replies if follow is posting with users from blocked domain %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) friend = insert(:user) blockee = insert(:user, ap_id: "https://example.com/users/blocked") - {:ok, blocker} = User.follow(blocker, friend) + {:ok, blocker, friend} = User.follow(blocker, friend) {:ok, blocker} = User.block_domain(blocker, "example.com") conn = assign(conn, :user, blocker) @@ -148,6 +183,60 @@ test "doesn't return replies if follow is posting with users from blocked domain activities = json_response_and_validate_schema(res_conn, 200) [%{"id" => ^activity_id}] = activities end + + test "can be filtered by instance", %{conn: conn} do + user = insert(:user, ap_id: "https://lain.com/users/lain") + insert(:note_activity, local: false) + insert(:note_activity, local: false) + + {:ok, _} = CommonAPI.post(user, %{status: "test"}) + + conn = get(conn, "/api/v1/timelines/public?instance=lain.com") + + assert length(json_response_and_validate_schema(conn, :ok)) == 1 + end + + test "muted emotions", %{conn: conn} do + user = insert(:user) + token = insert(:oauth_token, user: user, scopes: ["read:statuses"]) + + conn = + conn + |> assign(:user, user) + |> assign(:token, token) + + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "."}) + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "๐ŸŽ…") + User.mute(user, other_user) + + result = + conn + |> get("/api/v1/timelines/public") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [] + } + } + ] = result + + result = + conn + |> get("/api/v1/timelines/public?with_muted=true") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "๐ŸŽ…"}] + } + } + ] = result + end end defp local_and_remote_activities do @@ -247,7 +336,7 @@ test "direct timeline", %{conn: conn} do user_one = insert(:user) user_two = insert(:user) - {:ok, user_two} = User.follow(user_two, user_one) + {:ok, user_two, user_one} = User.follow(user_two, user_one) {:ok, direct} = CommonAPI.post(user_one, %{ @@ -417,6 +506,44 @@ test "list timeline does not leak non-public statuses for unfollowed users", %{ assert id == to_string(activity_one.id) end + + test "muted emotions", %{user: user, conn: conn} do + user2 = insert(:user) + user3 = insert(:user) + {:ok, activity} = CommonAPI.post(user2, %{status: "."}) + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, user3, "๐ŸŽ…") + User.mute(user, user3) + + {:ok, list} = Pleroma.List.create("name", user) + {:ok, list} = Pleroma.List.follow(list, user2) + + result = + conn + |> get("/api/v1/timelines/list/#{list.id}") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [] + } + } + ] = result + + result = + conn + |> get("/api/v1/timelines/list/#{list.id}?with_muted=true") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "๐ŸŽ…"}] + } + } + ] = result + end end describe "hashtag" do @@ -465,6 +592,48 @@ test "multi-hashtag timeline", %{conn: conn} do assert [status_none] == json_response_and_validate_schema(all_test, :ok) end + + test "muted emotions", %{conn: conn} do + user = insert(:user) + token = insert(:oauth_token, user: user, scopes: ["read:statuses"]) + + conn = + conn + |> assign(:user, user) + |> assign(:token, token) + + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "test #2hu"}) + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "๐ŸŽ…") + User.mute(user, other_user) + + result = + conn + |> get("/api/v1/timelines/tag/2hu") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [] + } + } + ] = result + + result = + conn + |> get("/api/v1/timelines/tag/2hu?with_muted=true") + |> json_response_and_validate_schema(200) + + assert [ + %{ + "pleroma" => %{ + "emoji_reactions" => [%{"count" => 1, "me" => false, "name" => "๐ŸŽ…"}] + } + } + ] = result + end end describe "hashtag timeline handling of :restrict_unauthenticated setting" do diff --git a/test/pleroma/web/mastodon_api/masto_fe_controller_test.exs b/test/pleroma/web/mastodon_api/masto_fe_controller_test.exs index ed8add8d2..b9cd050df 100644 --- a/test/pleroma/web/mastodon_api/masto_fe_controller_test.exs +++ b/test/pleroma/web/mastodon_api/masto_fe_controller_test.exs @@ -64,7 +64,8 @@ test "redirects not logged-in users to the login page on private instances", %{ end test "does not redirect logged in users to the login page", %{conn: conn, path: path} do - token = insert(:oauth_token, scopes: ["read"]) + {:ok, app} = Pleroma.Web.MastodonAPI.AuthController.local_mastofe_app() + token = insert(:oauth_token, app: app, scopes: ["read"]) conn = conn diff --git a/test/pleroma/web/mastodon_api/mastodon_api_controller_test.exs b/test/pleroma/web/mastodon_api/mastodon_api_controller_test.exs index bb4bc4396..be5bf68a3 100644 --- a/test/pleroma/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/pleroma/web/mastodon_api/mastodon_api_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true describe "empty_array/2 (stubs)" do test "GET /api/v1/accounts/:id/identity_proofs" do diff --git a/test/pleroma/web/mastodon_api/mastodon_api_test.exs b/test/pleroma/web/mastodon_api/mastodon_api_test.exs index 0c5a38bf6..cf7f464be 100644 --- a/test/pleroma/web/mastodon_api/mastodon_api_test.exs +++ b/test/pleroma/web/mastodon_api/mastodon_api_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MastodonAPITest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Notification alias Pleroma.ScheduledActivity @@ -30,7 +30,7 @@ test "following for user" do test "returns ok if user already followed" do follower = insert(:user) user = insert(:user) - {:ok, follower} = User.follow(follower, user) + {:ok, follower, user} = User.follow(follower, user) {:ok, follower} = MastodonAPI.follow(follower, refresh_record(user)) assert User.following?(follower, user) end @@ -41,8 +41,8 @@ test "returns user followers" do follower1_user = insert(:user) follower2_user = insert(:user) user = insert(:user) - {:ok, _follower1_user} = User.follow(follower1_user, user) - {:ok, follower2_user} = User.follow(follower2_user, user) + {:ok, _follower1_user, _user} = User.follow(follower1_user, user) + {:ok, follower2_user, _user} = User.follow(follower2_user, user) assert MastodonAPI.get_followers(user, %{"limit" => 1}) == [follower2_user] end @@ -55,9 +55,9 @@ test "returns user friends" do followed_two = insert(:user) followed_three = insert(:user) - {:ok, user} = User.follow(user, followed_one) - {:ok, user} = User.follow(user, followed_two) - {:ok, user} = User.follow(user, followed_three) + {:ok, user, followed_one} = User.follow(user, followed_one) + {:ok, user, followed_two} = User.follow(user, followed_two) + {:ok, user, followed_three} = User.follow(user, followed_three) res = MastodonAPI.get_friends(user) assert length(res) == 3 diff --git a/test/pleroma/web/mastodon_api/update_credentials_test.exs b/test/pleroma/web/mastodon_api/update_credentials_test.exs index e99508cd8..ff0147244 100644 --- a/test/pleroma/web/mastodon_api/update_credentials_test.exs +++ b/test/pleroma/web/mastodon_api/update_credentials_test.exs @@ -11,8 +11,6 @@ defmodule Pleroma.Web.MastodonAPI.UpdateCredentialsTest do import Mock import Pleroma.Factory - setup do: clear_config([:instance, :max_account_fields]) - describe "updating credentials" do setup do: oauth_access(["write:accounts"]) setup :request_content_type @@ -456,7 +454,7 @@ test "update fields when invalid request", %{conn: conn} do |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) |> json_response_and_validate_schema(403) - Pleroma.Config.put([:instance, :max_account_fields], 1) + clear_config([:instance, :max_account_fields], 1) fields = [ %{"name" => "foo", "value" => "bar"}, diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index 482806c67..f9afd0afc 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -277,10 +277,10 @@ test "represent a relationship for the following and followed user" do user = insert(:user) other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) - {:ok, other_user} = User.follow(other_user, user) + {:ok, user, other_user} = User.follow(user, other_user) + {:ok, other_user, user} = User.follow(other_user, user) {:ok, _subscription} = User.subscribe(user, other_user) - {:ok, _user_relationships} = User.mute(user, other_user, true) + {:ok, _user_relationships} = User.mute(user, other_user, %{notifications: true}) {:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, other_user) expected = @@ -304,7 +304,7 @@ test "represent a relationship for the blocking and blocked user" do user = insert(:user) other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) + {:ok, user, other_user} = User.follow(user, other_user) {:ok, _subscription} = User.subscribe(user, other_user) {:ok, _user_relationship} = User.block(user, other_user) {:ok, _user_relationship} = User.block(other_user, user) diff --git a/test/pleroma/web/mastodon_api/views/conversation_view_test.exs b/test/pleroma/web/mastodon_api/views/conversation_view_test.exs index 2e8203c9b..f02253b68 100644 --- a/test/pleroma/web/mastodon_api/views/conversation_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/conversation_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ConversationViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Conversation.Participation alias Pleroma.Web.CommonAPI @@ -36,9 +36,11 @@ test "represents a Mastodon Conversation entity" do assert conversation.id == participation.id |> to_string() assert conversation.last_status.id == activity.id + assert conversation.last_status.account.id == user.id assert [account] = conversation.accounts assert account.id == other_user.id + assert conversation.last_status.pleroma.direct_conversation_id == participation.id end end diff --git a/test/pleroma/web/mastodon_api/views/list_view_test.exs b/test/pleroma/web/mastodon_api/views/list_view_test.exs index ca99242cb..377941332 100644 --- a/test/pleroma/web/mastodon_api/views/list_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/list_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ListViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.MastodonAPI.ListView diff --git a/test/pleroma/web/mastodon_api/views/marker_view_test.exs b/test/pleroma/web/mastodon_api/views/marker_view_test.exs index 48a0a6d33..a0bec758f 100644 --- a/test/pleroma/web/mastodon_api/views/marker_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/marker_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.MastodonAPI.MarkerView import Pleroma.Factory diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs index 2f6a808f1..9de11a87e 100644 --- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs @@ -12,6 +12,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView @@ -207,6 +209,26 @@ test "EmojiReact notification" do test_notifications_rendering([notification], user, [expected]) end + test "Report notification" do + reporting_user = insert(:user) + reported_user = insert(:user) + {:ok, moderator_user} = insert(:user) |> User.admin_api_update(%{is_moderator: true}) + + {:ok, activity} = CommonAPI.report(reporting_user, %{account_id: reported_user.id}) + {:ok, [notification]} = Notification.create_notifications(activity) + + expected = %{ + id: to_string(notification.id), + pleroma: %{is_seen: false, is_muted: false}, + type: "pleroma:report", + account: AccountView.render("show.json", %{user: reporting_user, for: moderator_user}), + created_at: Utils.to_masto_date(notification.inserted_at), + report: ReportView.render("show.json", Report.extract_report_info(activity)) + } + + test_notifications_rendering([notification], moderator_user, [expected]) + end + test "muted notification" do user = insert(:user) another_user = insert(:user) diff --git a/test/pleroma/web/mastodon_api/views/poll_view_test.exs b/test/pleroma/web/mastodon_api/views/poll_view_test.exs index b7e2f17ef..c655ca438 100644 --- a/test/pleroma/web/mastodon_api/views/poll_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/poll_view_test.exs @@ -44,7 +44,7 @@ test "renders a poll" do ], voted: false, votes_count: 0, - voters_count: nil + voters_count: 0 } result = PollView.render("show.json", %{object: object}) diff --git a/test/pleroma/web/mastodon_api/views/scheduled_activity_view_test.exs b/test/pleroma/web/mastodon_api/views/scheduled_activity_view_test.exs index 04f73f5a0..c41ac7f7f 100644 --- a/test/pleroma/web/mastodon_api/views/scheduled_activity_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/scheduled_activity_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.ScheduledActivityViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.ScheduledActivity alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 70d829979..fa9066716 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -73,6 +73,50 @@ test "works correctly with badly formatted emojis" do ] end + test "doesn't show reactions from muted and blocked users" do + user = insert(:user) + other_user = insert(:user) + third_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "dae cofe??"}) + + {:ok, _} = User.mute(user, other_user) + {:ok, _} = User.block(other_user, third_user) + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "โ˜•") + + activity = Repo.get(Activity, activity.id) + status = StatusView.render("show.json", activity: activity) + + assert status[:pleroma][:emoji_reactions] == [ + %{name: "โ˜•", count: 1, me: false} + ] + + status = StatusView.render("show.json", activity: activity, for: user) + + assert status[:pleroma][:emoji_reactions] == [] + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, third_user, "โ˜•") + + status = StatusView.render("show.json", activity: activity) + + assert status[:pleroma][:emoji_reactions] == [ + %{name: "โ˜•", count: 2, me: false} + ] + + status = StatusView.render("show.json", activity: activity, for: user) + + assert status[:pleroma][:emoji_reactions] == [ + %{name: "โ˜•", count: 1, me: false} + ] + + status = StatusView.render("show.json", activity: activity, for: other_user) + + assert status[:pleroma][:emoji_reactions] == [ + %{name: "โ˜•", count: 1, me: true} + ] + end + test "loads and returns the direct conversation id when given the `with_direct_conversation_id` option" do user = insert(:user) @@ -116,7 +160,7 @@ test "returns a temporary ap_id based user for activities missing db users" do {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"}) Repo.delete(user) - Cachex.clear(:user_cache) + User.invalidate_cache(user) finger_url = "https://localhost/.well-known/webfinger?resource=acct:#{user.nickname}@localhost" @@ -150,7 +194,7 @@ test "tries to get a user by nickname if fetching by ap_id doesn't work" do |> Ecto.Changeset.change(%{ap_id: "#{user.ap_id}/extension/#{user.nickname}"}) |> Repo.update() - Cachex.clear(:user_cache) + User.invalidate_cache(user) result = StatusView.render("show.json", activity: activity) @@ -420,6 +464,7 @@ test "attachments" do "href" => "someurl" } ], + "blurhash" => "UJJ8X[xYW,%Jtq%NNFbXB5j]IVM|9GV=WHRn", "uuid" => 6 } @@ -431,7 +476,8 @@ test "attachments" do preview_url: "someurl", text_url: "someurl", description: nil, - pleroma: %{mime_type: "image/png"} + pleroma: %{mime_type: "image/png"}, + blurhash: "UJJ8X[xYW,%Jtq%NNFbXB5j]IVM|9GV=WHRn" } api_spec = Pleroma.Web.ApiSpec.spec() diff --git a/test/pleroma/web/mastodon_api/views/subscription_view_test.exs b/test/pleroma/web/mastodon_api/views/subscription_view_test.exs index 981524c0e..c2bb535c5 100644 --- a/test/pleroma/web/mastodon_api/views/subscription_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/subscription_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.SubscriptionViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.MastodonAPI.SubscriptionView, as: View alias Pleroma.Web.Push diff --git a/test/pleroma/web/media_proxy/invalidation/http_test.exs b/test/pleroma/web/media_proxy/invalidation/http_test.exs index 13d081325..c81010423 100644 --- a/test/pleroma/web/media_proxy/invalidation/http_test.exs +++ b/test/pleroma/web/media_proxy/invalidation/http_test.exs @@ -9,10 +9,6 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.HttpTest do import ExUnit.CaptureLog import Tesla.Mock - setup do - on_exit(fn -> Cachex.clear(:banned_urls_cache) end) - end - test "logs hasn't error message when request is valid" do mock(fn %{method: :purge, url: "http://example.com/media/example.jpg"} -> diff --git a/test/pleroma/web/media_proxy/invalidation/script_test.exs b/test/pleroma/web/media_proxy/invalidation/script_test.exs index 692cbb2df..6940a4539 100644 --- a/test/pleroma/web/media_proxy/invalidation/script_test.exs +++ b/test/pleroma/web/media_proxy/invalidation/script_test.exs @@ -3,15 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MediaProxy.Invalidation.ScriptTest do - use ExUnit.Case + use ExUnit.Case, async: true alias Pleroma.Web.MediaProxy.Invalidation import ExUnit.CaptureLog - setup do - on_exit(fn -> Cachex.clear(:banned_urls_cache) end) - end - test "it logger error when script not found" do assert capture_log(fn -> assert Invalidation.Script.purge( diff --git a/test/pleroma/web/media_proxy/invalidation_test.exs b/test/pleroma/web/media_proxy/invalidation_test.exs index aa1435ac0..b7be36b47 100644 --- a/test/pleroma/web/media_proxy/invalidation_test.exs +++ b/test/pleroma/web/media_proxy/invalidation_test.exs @@ -3,8 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MediaProxy.InvalidationTest do - use ExUnit.Case - use Pleroma.Tests.Helpers + use Pleroma.DataCase alias Pleroma.Config alias Pleroma.Web.MediaProxy.Invalidation @@ -15,10 +14,6 @@ defmodule Pleroma.Web.MediaProxy.InvalidationTest do setup do: clear_config([:media_proxy]) - setup do - on_exit(fn -> Cachex.clear(:banned_urls_cache) end) - end - describe "Invalidation.Http" do test "perform request to clear cache" do Config.put([:media_proxy, :enabled], false) diff --git a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs index e9b584822..65cf2a01b 100644 --- a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs +++ b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs @@ -10,10 +10,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do alias Pleroma.Web.MediaProxy alias Plug.Conn - setup do - on_exit(fn -> Cachex.clear(:banned_urls_cache) end) - end - describe "Media Proxy" do setup do clear_config([:media_proxy, :enabled], true) diff --git a/test/pleroma/web/metadata/player_view_test.exs b/test/pleroma/web/metadata/player_view_test.exs index e6c990242..6d22317d2 100644 --- a/test/pleroma/web/metadata/player_view_test.exs +++ b/test/pleroma/web/metadata/player_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.PlayerViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.Metadata.PlayerView diff --git a/test/pleroma/web/metadata/providers/feed_test.exs b/test/pleroma/web/metadata/providers/feed_test.exs index e6e5cc5ed..c7359e00b 100644 --- a/test/pleroma/web/metadata/providers/feed_test.exs +++ b/test/pleroma/web/metadata/providers/feed_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Providers.FeedTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.Metadata.Providers.Feed diff --git a/test/pleroma/web/metadata/providers/rel_me_test.exs b/test/pleroma/web/metadata/providers/rel_me_test.exs index 2293d6e13..ae449c052 100644 --- a/test/pleroma/web/metadata/providers/rel_me_test.exs +++ b/test/pleroma/web/metadata/providers/rel_me_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Providers.RelMeTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.Metadata.Providers.RelMe diff --git a/test/pleroma/web/metadata/providers/restrict_indexing_test.exs b/test/pleroma/web/metadata/providers/restrict_indexing_test.exs index 6b3a65372..52399fdc8 100644 --- a/test/pleroma/web/metadata/providers/restrict_indexing_test.exs +++ b/test/pleroma/web/metadata/providers/restrict_indexing_test.exs @@ -14,13 +14,13 @@ test "for remote user" do test "for local user" do assert Pleroma.Web.Metadata.Providers.RestrictIndexing.build_tags(%{ - user: %Pleroma.User{local: true, discoverable: true} + user: %Pleroma.User{local: true, is_discoverable: true} }) == [] end - test "for local user when discoverable is false" do + test "for local user when `is_discoverable` is false" do assert Pleroma.Web.Metadata.Providers.RestrictIndexing.build_tags(%{ - user: %Pleroma.User{local: true, discoverable: false} + user: %Pleroma.User{local: true, is_discoverable: false} }) == [{:meta, [name: "robots", content: "noindex, noarchive"], []}] end end diff --git a/test/pleroma/web/metadata/utils_test.exs b/test/pleroma/web/metadata/utils_test.exs index 8183256d8..3794db766 100644 --- a/test/pleroma/web/metadata/utils_test.exs +++ b/test/pleroma/web/metadata/utils_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.UtilsTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.Metadata.Utils diff --git a/test/pleroma/web/metadata_test.exs b/test/pleroma/web/metadata_test.exs deleted file mode 100644 index ca6cbe67f..000000000 --- a/test/pleroma/web/metadata_test.exs +++ /dev/null @@ -1,49 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright ยฉ 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.MetadataTest do - use Pleroma.DataCase, async: true - - import Pleroma.Factory - - describe "restrict indexing remote users" do - test "for remote user" do - user = insert(:user, local: false) - - assert Pleroma.Web.Metadata.build_tags(%{user: user}) =~ - "" - end - - test "for local user" do - user = insert(:user, discoverable: false) - - assert Pleroma.Web.Metadata.build_tags(%{user: user}) =~ - "" - end - - test "for local user set to discoverable" do - user = insert(:user, discoverable: true) - - refute Pleroma.Web.Metadata.build_tags(%{user: user}) =~ - "" - end - end - - describe "no metadata for private instances" do - test "for local user set to discoverable" do - clear_config([:instance, :public], false) - user = insert(:user, bio: "This is my secret fedi account bio", discoverable: true) - - assert "" = Pleroma.Web.Metadata.build_tags(%{user: user}) - end - - test "search exclusion metadata is included" do - clear_config([:instance, :public], false) - user = insert(:user, bio: "This is my secret fedi account bio", discoverable: false) - - assert ~s() == - Pleroma.Web.Metadata.build_tags(%{user: user}) - end - end -end diff --git a/test/pleroma/web/mongoose_im_controller_test.exs b/test/pleroma/web/mongoose_im_controller_test.exs index e3a8aa3d8..4590e1296 100644 --- a/test/pleroma/web/mongoose_im_controller_test.exs +++ b/test/pleroma/web/mongoose_im_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MongooseIMControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory test "/user_exists", %{conn: conn} do diff --git a/test/pleroma/web/o_auth/app_test.exs b/test/pleroma/web/o_auth/app_test.exs index 993a490e0..24d7049f1 100644 --- a/test/pleroma/web/o_auth/app_test.exs +++ b/test/pleroma/web/o_auth/app_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.AppTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.OAuth.App import Pleroma.Factory diff --git a/test/pleroma/web/o_auth/authorization_test.exs b/test/pleroma/web/o_auth/authorization_test.exs index d74b26cf8..d1920962c 100644 --- a/test/pleroma/web/o_auth/authorization_test.exs +++ b/test/pleroma/web/o_auth/authorization_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.AuthorizationTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization import Pleroma.Factory diff --git a/test/pleroma/web/o_auth/mfa_controller_test.exs b/test/pleroma/web/o_auth/mfa_controller_test.exs index 3c341facd..bc50d8d18 100644 --- a/test/pleroma/web/o_auth/mfa_controller_test.exs +++ b/test/pleroma/web/o_auth/mfa_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.MFAControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory alias Pleroma.MFA @@ -171,7 +171,6 @@ test "returns access token with valid code", %{conn: conn, user: user, app: app} assert match?( %{ "access_token" => _, - "expires_in" => 600, "me" => ^ap_id, "refresh_token" => _, "scope" => "write", @@ -280,7 +279,6 @@ test "returns access token with valid code", %{conn: conn, app: app} do assert match?( %{ "access_token" => _, - "expires_in" => 600, "me" => ^ap_id, "refresh_token" => _, "scope" => "write", diff --git a/test/pleroma/web/o_auth/o_auth_controller_test.exs b/test/pleroma/web/o_auth/o_auth_controller_test.exs index 1200126b8..ac22856ea 100644 --- a/test/pleroma/web/o_auth/o_auth_controller_test.exs +++ b/test/pleroma/web/o_auth/o_auth_controller_test.exs @@ -4,8 +4,10 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do use Pleroma.Web.ConnCase + import Pleroma.Factory + alias Pleroma.Helpers.AuthHelper alias Pleroma.MFA alias Pleroma.MFA.TOTP alias Pleroma.Repo @@ -77,11 +79,11 @@ test "GET /oauth/prepare_request encodes parameters as `state` and redirects", % } ) - assert response = html_response(conn, 302) + assert html_response(conn, 302) redirect_query = URI.parse(redirected_to(conn)).query assert %{"state" => state_param} = URI.decode_query(redirect_query) - assert {:ok, state_components} = Poison.decode(state_param) + assert {:ok, state_components} = Jason.decode(state_param) expected_client_id = app.client_id expected_redirect_uri = app.redirect_uris @@ -115,11 +117,11 @@ test "with user-bound registration, GET /oauth//callback redirects to "oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM", "oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs", "provider" => "twitter", - "state" => Poison.encode!(state_params) + "state" => Jason.encode!(state_params) } ) - assert response = html_response(conn, 302) + assert html_response(conn, 302) assert redirected_to(conn) =~ ~r/#{redirect_uri}\?code=.+/ end @@ -147,7 +149,7 @@ test "with user-unbound registration, GET /oauth//callback renders reg "oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM", "oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs", "provider" => "twitter", - "state" => Poison.encode!(state_params) + "state" => Jason.encode!(state_params) } ) @@ -178,11 +180,11 @@ test "on authentication error, GET /oauth//callback redirects to `redi "oauth_token" => "G-5a3AAAAAAAwMH9AAABaektfSM", "oauth_verifier" => "QZl8vUqNvXMTKpdmUnGejJxuHG75WWWs", "provider" => "twitter", - "state" => Poison.encode!(state_params) + "state" => Jason.encode!(state_params) } ) - assert response = html_response(conn, 302) + assert html_response(conn, 302) assert redirected_to(conn) == app.redirect_uris assert get_flash(conn, :error) == "Failed to authenticate: (error description)." end @@ -238,7 +240,7 @@ test "with valid params, POST /oauth/register?op=register redirects to `redirect } ) - assert response = html_response(conn, 302) + assert html_response(conn, 302) assert redirected_to(conn) =~ ~r/#{redirect_uri}\?code=.+/ end @@ -268,7 +270,7 @@ test "with unlisted `redirect_uri`, POST /oauth/register?op=register results in } ) - assert response = html_response(conn, 401) + assert html_response(conn, 401) end test "with invalid params, POST /oauth/register?op=register renders registration_details page", @@ -336,7 +338,7 @@ test "with valid params, POST /oauth/register?op=connect redirects to `redirect_ } ) - assert response = html_response(conn, 302) + assert html_response(conn, 302) assert redirected_to(conn) =~ ~r/#{redirect_uri}\?code=.+/ end @@ -367,7 +369,7 @@ test "with unlisted `redirect_uri`, POST /oauth/register?op=connect results in H } ) - assert response = html_response(conn, 401) + assert html_response(conn, 401) end test "with invalid params, POST /oauth/register?op=connect renders registration_details page", @@ -454,7 +456,7 @@ test "renders authentication page if user is already authenticated but `force_lo conn = conn - |> put_session(:oauth_token, token.token) + |> AuthHelper.put_session_token(token.token) |> get( "/oauth/authorize", %{ @@ -478,7 +480,7 @@ test "renders authentication page if user is already authenticated but user requ conn = conn - |> put_session(:oauth_token, token.token) + |> AuthHelper.put_session_token(token.token) |> get( "/oauth/authorize", %{ @@ -501,7 +503,7 @@ test "with existing authentication and non-OOB `redirect_uri`, redirects to app conn = conn - |> put_session(:oauth_token, token.token) + |> AuthHelper.put_session_token(token.token) |> get( "/oauth/authorize", %{ @@ -527,7 +529,7 @@ test "with existing authentication and unlisted non-OOB `redirect_uri`, redirect conn = conn - |> put_session(:oauth_token, token.token) + |> AuthHelper.put_session_token(token.token) |> get( "/oauth/authorize", %{ @@ -551,7 +553,7 @@ test "with existing authentication and OOB `redirect_uri`, redirects to app with conn = conn - |> put_session(:oauth_token, token.token) + |> AuthHelper.put_session_token(token.token) |> get( "/oauth/authorize", %{ @@ -609,6 +611,41 @@ test "redirects with oauth authorization, " <> end end + test "authorize from cookie" do + user = insert(:user) + app = insert(:oauth_app) + oauth_token = insert(:oauth_token, user: user, app: app) + redirect_uri = OAuthController.default_redirect_uri(app) + + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(oauth_token.token) + |> post( + "/oauth/authorize", + %{ + "authorization" => %{ + "name" => user.nickname, + "client_id" => app.client_id, + "redirect_uri" => redirect_uri, + "scope" => app.scopes, + "state" => "statepassed" + } + } + ) + + target = redirected_to(conn) + assert target =~ redirect_uri + + query = URI.parse(target).query |> URI.query_decoder() |> Map.new() + + assert %{"state" => "statepassed", "code" => code} = query + auth = Repo.get_by(Authorization, token: code) + assert auth + assert auth.scopes == app.scopes + end + test "redirect to on two-factor auth page" do otp_secret = TOTP.generate_secret() @@ -1068,7 +1105,6 @@ test "issues a new access token with keep fresh token" do %{ "scope" => "write", "token_type" => "Bearer", - "expires_in" => 600, "access_token" => _, "refresh_token" => _, "me" => ^ap_id @@ -1108,7 +1144,6 @@ test "issues a new access token with new fresh token" do %{ "scope" => "write", "token_type" => "Bearer", - "expires_in" => 600, "access_token" => _, "refresh_token" => _, "me" => ^ap_id @@ -1191,7 +1226,6 @@ test "issues a new token if token expired" do %{ "scope" => "write", "token_type" => "Bearer", - "expires_in" => 600, "access_token" => _, "refresh_token" => _, "me" => ^ap_id @@ -1219,8 +1253,43 @@ test "returns 500" do end end - describe "POST /oauth/revoke - bad request" do - test "returns 500" do + describe "POST /oauth/revoke" do + test "when authenticated with request token, revokes it and clears it from session" do + oauth_token = insert(:oauth_token) + + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(oauth_token.token) + |> post("/oauth/revoke", %{"token" => oauth_token.token}) + + assert json_response(conn, 200) + + refute AuthHelper.get_session_token(conn) + assert Token.get_by_token(oauth_token.token) == {:error, :not_found} + end + + test "if request is authenticated with a different token, " <> + "revokes requested token but keeps session token" do + user = insert(:user) + oauth_token = insert(:oauth_token, user: user) + other_app_oauth_token = insert(:oauth_token, user: user) + + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(oauth_token.token) + |> post("/oauth/revoke", %{"token" => other_app_oauth_token.token}) + + assert json_response(conn, 200) + + assert AuthHelper.get_session_token(conn) == oauth_token.token + assert Token.get_by_token(other_app_oauth_token.token) == {:error, :not_found} + end + + test "returns 500 on bad request" do response = build_conn() |> post("/oauth/revoke", %{}) diff --git a/test/pleroma/web/o_auth/token/utils_test.exs b/test/pleroma/web/o_auth/token/utils_test.exs index a610d92f8..3444692ec 100644 --- a/test/pleroma/web/o_auth/token/utils_test.exs +++ b/test/pleroma/web/o_auth/token/utils_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token.UtilsTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.OAuth.Token.Utils import Pleroma.Factory diff --git a/test/pleroma/web/o_auth/token_test.exs b/test/pleroma/web/o_auth/token_test.exs index c88b9cc98..866f1c00a 100644 --- a/test/pleroma/web/o_auth/token_test.exs +++ b/test/pleroma/web/o_auth/token_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.TokenTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Repo alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization diff --git a/test/pleroma/web/o_status/o_status_controller_test.exs b/test/pleroma/web/o_status/o_status_controller_test.exs index ee498f4b5..65b2c22db 100644 --- a/test/pleroma/web/o_status/o_status_controller_test.exs +++ b/test/pleroma/web/o_status/o_status_controller_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do import Pleroma.Factory - alias Pleroma.Config alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -21,7 +20,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do :ok end - setup do: clear_config([:instance, :federating], true) + setup do: clear_config([:static_fe, :enabled], false) describe "Mastodon compatibility routes" do setup %{conn: conn} do @@ -215,15 +214,16 @@ test "404s a non-existing notice", %{conn: conn} do assert response(conn, 404) end - test "it requires authentication if instance is NOT federating", %{ + test "does not require authentication on non-federating instances", %{ conn: conn } do - user = insert(:user) + clear_config([:instance, :federating], false) note_activity = insert(:note_activity) - conn = put_req_header(conn, "accept", "text/html") - - ensure_federating_or_authenticated(conn, "/notice/#{note_activity.id}", user) + conn + |> put_req_header("accept", "text/html") + |> get("/notice/#{note_activity.id}") + |> response(200) end end @@ -325,14 +325,16 @@ test "404s when attachment isn't audio or video", %{conn: conn} do |> response(404) end - test "it requires authentication if instance is NOT federating", %{ + test "does not require authentication on non-federating instances", %{ conn: conn, note_activity: note_activity } do - user = insert(:user) - conn = put_req_header(conn, "accept", "text/html") + clear_config([:instance, :federating], false) - ensure_federating_or_authenticated(conn, "/notice/#{note_activity.id}/embed_player", user) + conn + |> put_req_header("accept", "text/html") + |> get("/notice/#{note_activity.id}/embed_player") + |> response(200) end end end diff --git a/test/pleroma/web/pleroma_api/controllers/backup_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/backup_controller_test.exs new file mode 100644 index 000000000..f1941f6dd --- /dev/null +++ b/test/pleroma/web/pleroma_api/controllers/backup_controller_test.exs @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.BackupControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.User.Backup + alias Pleroma.Web.PleromaAPI.BackupView + + setup do + clear_config([Pleroma.Upload, :uploader]) + clear_config([Backup, :limit_days]) + oauth_access(["read:accounts"]) + end + + test "GET /api/v1/pleroma/backups", %{user: user, conn: conn} do + assert {:ok, %Oban.Job{args: %{"backup_id" => backup_id}}} = Backup.create(user) + + backup = Backup.get(backup_id) + + response = + conn + |> get("/api/v1/pleroma/backups") + |> json_response_and_validate_schema(:ok) + + assert [ + %{ + "content_type" => "application/zip", + "url" => url, + "file_size" => 0, + "processed" => false, + "inserted_at" => _ + } + ] = response + + assert url == BackupView.download_url(backup) + + Pleroma.Tests.ObanHelpers.perform_all() + + assert [ + %{ + "url" => ^url, + "processed" => true + } + ] = + conn + |> get("/api/v1/pleroma/backups") + |> json_response_and_validate_schema(:ok) + end + + test "POST /api/v1/pleroma/backups", %{user: _user, conn: conn} do + assert [ + %{ + "content_type" => "application/zip", + "url" => url, + "file_size" => 0, + "processed" => false, + "inserted_at" => _ + } + ] = + conn + |> post("/api/v1/pleroma/backups") + |> json_response_and_validate_schema(:ok) + + Pleroma.Tests.ObanHelpers.perform_all() + + assert [ + %{ + "url" => ^url, + "processed" => true + } + ] = + conn + |> get("/api/v1/pleroma/backups") + |> json_response_and_validate_schema(:ok) + + days = Pleroma.Config.get([Backup, :limit_days]) + + assert %{"error" => "Last export was less than #{days} days ago"} == + conn + |> post("/api/v1/pleroma/backups") + |> json_response_and_validate_schema(400) + end +end diff --git a/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs index 6381f9757..415c3decd 100644 --- a/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs @@ -82,11 +82,13 @@ test "it posts a message to the chat", %{conn: conn, user: user} do result = conn |> put_req_header("content-type", "application/json") + |> put_req_header("idempotency-key", "123") |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) |> json_response_and_validate_schema(200) assert result["content"] == "Hallo!!" assert result["chat_id"] == chat.id |> to_string() + assert result["idempotency_key"] == "123" end test "it fails if there is no content", %{conn: conn, user: user} do @@ -262,9 +264,10 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do assert length(result) == 3 # Trying to get the chat of a different user + other_user_chat = Chat.get(other_user.id, user.ap_id) + conn - |> assign(:user, other_user) - |> get("/api/v1/pleroma/chats/#{chat.id}/messages") + |> get("/api/v1/pleroma/chats/#{other_user_chat.id}/messages") |> json_response_and_validate_schema(404) end end @@ -341,6 +344,35 @@ test "it does not return chats with users you blocked", %{conn: conn, user: user assert length(result) == 0 end + test "it does not return chats with users you muted", %{conn: conn, user: user} do + recipient = insert(:user) + + {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) + + result = + conn + |> get("/api/v1/pleroma/chats") + |> json_response_and_validate_schema(200) + + assert length(result) == 1 + + User.mute(user, recipient) + + result = + conn + |> get("/api/v1/pleroma/chats") + |> json_response_and_validate_schema(200) + + assert length(result) == 0 + + result = + conn + |> get("/api/v1/pleroma/chats?with_muted=true") + |> json_response_and_validate_schema(200) + + assert length(result) == 1 + end + test "it returns all chats", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> recipient = insert(:user) @@ -362,11 +394,11 @@ test "it return a list of chats the current user is participating in, in descend tridi = insert(:user) {:ok, chat_1} = Chat.get_or_create(user.id, har.ap_id) - :timer.sleep(1000) - {:ok, _chat_2} = Chat.get_or_create(user.id, jafnhar.ap_id) - :timer.sleep(1000) + {:ok, chat_1} = time_travel(chat_1, -3) + {:ok, chat_2} = Chat.get_or_create(user.id, jafnhar.ap_id) + {:ok, _chat_2} = time_travel(chat_2, -2) {:ok, chat_3} = Chat.get_or_create(user.id, tridi.ap_id) - :timer.sleep(1000) + {:ok, chat_3} = time_travel(chat_3, -1) # bump the second one {:ok, chat_2} = Chat.bump_or_create(user.id, jafnhar.ap_id) diff --git a/test/pleroma/web/pleroma_api/controllers/conversation_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/conversation_controller_test.exs index e6d0b3e37..c8c2433ae 100644 --- a/test/pleroma/web/pleroma_api/controllers/conversation_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/conversation_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.ConversationControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Conversation.Participation alias Pleroma.Repo @@ -121,7 +121,7 @@ test "POST /api/v1/pleroma/conversations/read" do [participation2, participation1] = Participation.for_user(other_user) assert Participation.get(participation2.id).read == false assert Participation.get(participation1.id).read == false - assert User.get_cached_by_id(other_user.id).unread_conversation_count == 2 + assert Participation.unread_count(other_user) == 2 [%{"unread" => false}, %{"unread" => false}] = conn @@ -131,6 +131,6 @@ test "POST /api/v1/pleroma/conversations/read" do [participation2, participation1] = Participation.for_user(other_user) assert Participation.get(participation2.id).read == true assert Participation.get(participation1.id).read == true - assert User.get_cached_by_id(other_user.id).unread_conversation_count == 0 + assert Participation.unread_count(other_user) == 0 end end diff --git a/test/pleroma/web/pleroma_api/controllers/emoji_file_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/emoji_file_controller_test.exs index 82de86ee3..6fbdaec7a 100644 --- a/test/pleroma/web/pleroma_api/controllers/emoji_file_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/emoji_file_controller_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiFileControllerTest do use Pleroma.Web.ConnCase + import Mock import Tesla.Mock import Pleroma.Factory @@ -200,6 +201,31 @@ test "add file with not loaded pack", %{admin_conn: admin_conn} do } end + test "returns an error on add file when file system is not writable", %{ + admin_conn: admin_conn + } do + pack_file = Path.join([@emoji_path, "not_loaded", "pack.json"]) + + with_mocks([ + {File, [:passthrough], [stat: fn ^pack_file -> {:error, :eacces} end]} + ]) do + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/files?name=not_loaded", %{ + shortcode: "blank3", + filename: "dir/blank.png", + file: %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response_and_validate_schema(500) == %{ + "error" => + "Unexpected error occurred while adding file to pack. (POSIX error: Permission denied)" + } + end + end + test "remove file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn |> delete("/api/pleroma/emoji/packs/files?name=not_loaded&shortcode=blank3") diff --git a/test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_test.exs index 386ad8634..d9385389b 100644 --- a/test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -3,8 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: false + import Mock import Tesla.Mock import Pleroma.Factory @@ -346,7 +347,7 @@ test "other error", %{admin_conn: admin_conn} do end end - describe "PATCH /api/pleroma/emoji/pack?name=:name" do + describe "PATCH/update /api/pleroma/emoji/pack?name=:name" do setup do pack_file = "#{@emoji_path}/test_pack/pack.json" original_content = File.read!(pack_file) @@ -365,6 +366,20 @@ test "other error", %{admin_conn: admin_conn} do }} end + test "returns error when file system not writable", %{admin_conn: conn} = ctx do + with_mocks([ + {File, [:passthrough], [stat: fn _ -> {:error, :eacces} end]} + ]) do + assert conn + |> put_req_header("content-type", "multipart/form-data") + |> patch( + "/api/pleroma/emoji/pack?name=test_pack", + %{"metadata" => ctx[:new_data]} + ) + |> json_response_and_validate_schema(500) + end + end + test "for a pack without a fallback source", ctx do assert ctx[:admin_conn] |> put_req_header("content-type", "multipart/form-data") @@ -424,6 +439,46 @@ test "when the fallback source doesn't have all the files", ctx do end describe "POST/DELETE /api/pleroma/emoji/pack?name=:name" do + test "returns an error on creates pack when file system not writable", %{ + admin_conn: admin_conn + } do + path_pack = Path.join(@emoji_path, "test_pack") + + with_mocks([ + {File, [:passthrough], [mkdir: fn ^path_pack -> {:error, :eacces} end]} + ]) do + assert admin_conn + |> post("/api/pleroma/emoji/pack?name=test_pack") + |> json_response_and_validate_schema(500) == %{ + "error" => + "Unexpected error occurred while creating pack. (POSIX error: Permission denied)" + } + end + end + + test "returns an error on deletes pack when the file system is not writable", %{ + admin_conn: admin_conn + } do + path_pack = Path.join(@emoji_path, "test_emoji_pack") + + try do + {:ok, _pack} = Pleroma.Emoji.Pack.create("test_emoji_pack") + + with_mocks([ + {File, [:passthrough], [rm_rf: fn ^path_pack -> {:error, :eacces, path_pack} end]} + ]) do + assert admin_conn + |> delete("/api/pleroma/emoji/pack?name=test_emoji_pack") + |> json_response_and_validate_schema(500) == %{ + "error" => + "Couldn't delete the `test_emoji_pack` pack (POSIX error: Permission denied)" + } + end + after + File.rm_rf(path_pack) + end + end + test "creating and deleting a pack", %{admin_conn: admin_conn} do assert admin_conn |> post("/api/pleroma/emoji/pack?name=test_created") @@ -569,7 +624,7 @@ test "shows pack.json", %{conn: conn} do test "for pack name with special chars", %{conn: conn} do assert %{ - "files" => files, + "files" => _files, "files_count" => 1, "pack" => %{ "can-download" => true, diff --git a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs index 3deab30d1..bda9c20c6 100644 --- a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs @@ -106,6 +106,48 @@ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do result end + test "GET /api/v1/pleroma/statuses/:id/reactions?with_muted=true", %{conn: conn} do + user = insert(:user) + user2 = insert(:user) + user3 = insert(:user) + + token = insert(:oauth_token, user: user, scopes: ["read:statuses"]) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, user2, "๐ŸŽ…") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, user3, "๐ŸŽ…") + + result = + conn + |> assign(:user, user) + |> assign(:token, token) + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") + |> json_response_and_validate_schema(200) + + assert [%{"name" => "๐ŸŽ…", "count" => 2}] = result + + User.mute(user, user3) + + result = + conn + |> assign(:user, user) + |> assign(:token, token) + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") + |> json_response_and_validate_schema(200) + + assert [%{"name" => "๐ŸŽ…", "count" => 1}] = result + + result = + conn + |> assign(:user, user) + |> assign(:token, token) + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions?with_muted=true") + |> json_response_and_validate_schema(200) + + assert [%{"name" => "๐ŸŽ…", "count" => 2}] = result + end + test "GET /api/v1/pleroma/statuses/:id/reactions with :show_reactions disabled", %{conn: conn} do clear_config([:instance, :show_reactions], false) diff --git a/test/pleroma/web/pleroma_api/controllers/instances_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/instances_controller_test.exs new file mode 100644 index 000000000..13491ed9c --- /dev/null +++ b/test/pleroma/web/pleroma_api/controllers/instances_controller_test.exs @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaApi.InstancesControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Instances + + setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1) + + setup do + constant = "http://consistently-unreachable.name/" + eventual = "http://eventually-unreachable.com/path" + + {:ok, %Pleroma.Instances.Instance{unreachable_since: constant_unreachable}} = + Instances.set_consistently_unreachable(constant) + + _eventual_unrechable = Instances.set_unreachable(eventual) + + %{constant_unreachable: constant_unreachable, constant: constant} + end + + test "GET /api/v1/pleroma/federation_status", %{ + conn: conn, + constant_unreachable: constant_unreachable, + constant: constant + } do + constant_host = URI.parse(constant).host + + assert conn + |> put_req_header("content-type", "application/json") + |> get("/api/v1/pleroma/federation_status") + |> json_response_and_validate_schema(200) == %{ + "unreachable" => %{constant_host => to_string(constant_unreachable)} + } + end +end diff --git a/test/pleroma/web/pleroma_api/controllers/mascot_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/mascot_controller_test.exs index d6be92869..5f8fa03f6 100644 --- a/test/pleroma/web/pleroma_api/controllers/mascot_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/mascot_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.MascotControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.User @@ -34,7 +34,7 @@ test "mascot upload" do |> put_req_header("content-type", "multipart/form-data") |> put("/api/v1/pleroma/mascot", %{"file" => file}) - assert %{"id" => _, "type" => image} = json_response_and_validate_schema(conn, 200) + assert %{"id" => _, "type" => _image} = json_response_and_validate_schema(conn, 200) end test "mascot retrieving" do diff --git a/test/pleroma/web/pleroma_api/controllers/notification_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/notification_controller_test.exs index bb4fe6c49..03af4d70c 100644 --- a/test/pleroma/web/pleroma_api/controllers/notification_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/notification_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.NotificationControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Notification alias Pleroma.Repo diff --git a/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs index f39c07ac6..4ab6d9132 100644 --- a/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Web.CommonAPI diff --git a/test/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs index 22988c881..8d4e0104a 100644 --- a/test/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true import Pleroma.Factory alias Pleroma.MFA.Settings diff --git a/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs index 433c97e81..d83d33912 100644 --- a/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do use Pleroma.Web.ConnCase use Oban.Testing, repo: Pleroma.Repo - alias Pleroma.Config alias Pleroma.Tests.ObanHelpers import Pleroma.Factory @@ -48,7 +47,8 @@ test "it imports follow lists from file", %{conn: conn} do |> json_response_and_validate_schema(200) assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == [user2] + assert job_result == [refresh_record(user2)] + assert [%Pleroma.User{follower_count: 1}] = job_result end end @@ -109,7 +109,7 @@ test "it imports follows with different nickname variations", %{conn: conn} do |> json_response_and_validate_schema(200) assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == users + assert job_result == Enum.map(users, &refresh_record/1) end end diff --git a/test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs b/test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs index f171a1e55..93eef00a2 100644 --- a/test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs +++ b/test/pleroma/web/pleroma_api/views/chat_message_reference_view_test.exs @@ -25,7 +25,9 @@ test "it displays a chat message" do } {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) - {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") + + {:ok, activity} = + CommonAPI.post_chat_message(user, recipient, "kippis :firefox:", idempotency_key: "123") chat = Chat.get(user.id, recipient.ap_id) @@ -42,10 +44,11 @@ test "it displays a chat message" do assert chat_message[:created_at] assert chat_message[:unread] == false assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) + assert chat_message[:idempotency_key] == "123" clear_config([:rich_media, :enabled], true) - Tesla.Mock.mock(fn + Tesla.Mock.mock_global(fn %{url: "https://example.com/ogp"} -> %Tesla.Env{status: 200, body: File.read!("test/fixtures/rich_media/ogp.html")} end) diff --git a/test/pleroma/web/pleroma_api/views/chat_view_test.exs b/test/pleroma/web/pleroma_api/views/chat_view_test.exs index 02484b705..b60b597e8 100644 --- a/test/pleroma/web/pleroma_api/views/chat_view_test.exs +++ b/test/pleroma/web/pleroma_api/views/chat_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.ChatViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Chat alias Pleroma.Chat.MessageReference diff --git a/test/pleroma/web/pleroma_api/views/scrobble_view_test.exs b/test/pleroma/web/pleroma_api/views/scrobble_view_test.exs index 0f43cbdc3..113b8f690 100644 --- a/test/pleroma/web/pleroma_api/views/scrobble_view_test.exs +++ b/test/pleroma/web/pleroma_api/views/scrobble_view_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.ScrobbleViewTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.PleromaAPI.ScrobbleView diff --git a/test/pleroma/web/plugs/admin_secret_authentication_plug_test.exs b/test/pleroma/web/plugs/admin_secret_authentication_plug_test.exs index 33394722a..23498badf 100644 --- a/test/pleroma/web/plugs/admin_secret_authentication_plug_test.exs +++ b/test/pleroma/web/plugs/admin_secret_authentication_plug_test.exs @@ -49,6 +49,7 @@ test "with `admin_token` query parameter", %{conn: conn} do |> AdminSecretAuthenticationPlug.call(%{}) assert conn.assigns[:user].is_admin + assert conn.assigns[:token] == nil assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) end @@ -69,6 +70,7 @@ test "with `x-admin-token` HTTP header", %{conn: conn} do |> AdminSecretAuthenticationPlug.call(%{}) assert conn.assigns[:user].is_admin + assert conn.assigns[:token] == nil assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) end end diff --git a/test/pleroma/web/plugs/authentication_plug_test.exs b/test/pleroma/web/plugs/authentication_plug_test.exs index af39352e2..3dedd38b2 100644 --- a/test/pleroma/web/plugs/authentication_plug_test.exs +++ b/test/pleroma/web/plugs/authentication_plug_test.exs @@ -48,6 +48,7 @@ test "with a correct password in the credentials, " <> |> AuthenticationPlug.call(%{}) assert conn.assigns.user == conn.assigns.auth_user + assert conn.assigns.token == nil assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) end @@ -62,6 +63,7 @@ test "with a bcrypt hash, it updates to a pkbdf2 hash", %{conn: conn} do |> AuthenticationPlug.call(%{}) assert conn.assigns.user.id == conn.assigns.auth_user.id + assert conn.assigns.token == nil assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) user = User.get_by_id(user.id) @@ -83,6 +85,7 @@ test "with a crypt hash, it updates to a pkbdf2 hash", %{conn: conn} do |> AuthenticationPlug.call(%{}) assert conn.assigns.user.id == conn.assigns.auth_user.id + assert conn.assigns.token == nil assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) user = User.get_by_id(user.id) diff --git a/test/pleroma/web/plugs/cache_control_test.exs b/test/pleroma/web/plugs/cache_control_test.exs index fcf3d2be8..c775787ca 100644 --- a/test/pleroma/web/plugs/cache_control_test.exs +++ b/test/pleroma/web/plugs/cache_control_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.CacheControlTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Plug.Conn test "Verify Cache-Control header on static assets", %{conn: conn} do diff --git a/test/pleroma/web/plugs/cache_test.exs b/test/pleroma/web/plugs/cache_test.exs index 93a66f5d3..0e5fa6f36 100644 --- a/test/pleroma/web/plugs/cache_test.exs +++ b/test/pleroma/web/plugs/cache_test.exs @@ -3,7 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.CacheTest do - use ExUnit.Case, async: true + # Relies on Cachex, has to stay synchronous + use Pleroma.DataCase use Plug.Test alias Pleroma.Web.Plugs.Cache @@ -24,11 +25,6 @@ defmodule Pleroma.Web.Plugs.CacheTest do @ttl 5 - setup do - Cachex.clear(:web_resp_cache) - :ok - end - test "caches a response" do assert @miss_resp == conn(:get, "/") diff --git a/test/pleroma/web/plugs/digest_plug_test.exs b/test/pleroma/web/plugs/digest_plug_test.exs new file mode 100644 index 000000000..629c28c93 --- /dev/null +++ b/test/pleroma/web/plugs/digest_plug_test.exs @@ -0,0 +1,48 @@ +defmodule Pleroma.Web.Plugs.DigestPlugTest do + use ExUnit.Case, async: true + use Plug.Test + + test "digest algorithm is taken from digest header" do + body = "{\"hello\": \"world\"}" + digest = "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" + + {:ok, ^body, conn} = + :get + |> conn("/", body) + |> put_req_header("content-type", "application/json") + |> put_req_header("digest", "sha-256=" <> digest) + |> Pleroma.Web.Plugs.DigestPlug.read_body([]) + + assert conn.assigns[:digest] == "sha-256=" <> digest + + {:ok, ^body, conn} = + :get + |> conn("/", body) + |> put_req_header("content-type", "application/json") + |> put_req_header("digest", "SHA-256=" <> digest) + |> Pleroma.Web.Plugs.DigestPlug.read_body([]) + + assert conn.assigns[:digest] == "SHA-256=" <> digest + end + + test "error if digest algorithm is invalid" do + body = "{\"hello\": \"world\"}" + digest = "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" + + assert_raise ArgumentError, "invalid value for digest algorithm, got: MD5", fn -> + :get + |> conn("/", body) + |> put_req_header("content-type", "application/json") + |> put_req_header("digest", "MD5=" <> digest) + |> Pleroma.Web.Plugs.DigestPlug.read_body([]) + end + + assert_raise ArgumentError, "invalid value for digest algorithm, got: md5", fn -> + :get + |> conn("/", body) + |> put_req_header("content-type", "application/json") + |> put_req_header("digest", "md5=" <> digest) + |> Pleroma.Web.Plugs.DigestPlug.read_body([]) + end + end +end diff --git a/test/pleroma/web/plugs/ensure_public_or_authenticated_plug_test.exs b/test/pleroma/web/plugs/ensure_public_or_authenticated_plug_test.exs index 211443a55..9f15f5c93 100644 --- a/test/pleroma/web/plugs/ensure_public_or_authenticated_plug_test.exs +++ b/test/pleroma/web/plugs/ensure_public_or_authenticated_plug_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlugTest do - use Pleroma.Web.ConnCase, async: true + use Pleroma.Web.ConnCase alias Pleroma.Config alias Pleroma.User diff --git a/test/pleroma/web/plugs/ensure_user_key_plug_test.exs b/test/pleroma/web/plugs/ensure_user_key_plug_test.exs deleted file mode 100644 index f912ef755..000000000 --- a/test/pleroma/web/plugs/ensure_user_key_plug_test.exs +++ /dev/null @@ -1,29 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright ยฉ 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Plugs.EnsureUserKeyPlugTest do - use Pleroma.Web.ConnCase, async: true - - alias Pleroma.Web.Plugs.EnsureUserKeyPlug - - test "if the conn has a user key set, it does nothing", %{conn: conn} do - conn = - conn - |> assign(:user, 1) - - ret_conn = - conn - |> EnsureUserKeyPlug.call(%{}) - - assert conn == ret_conn - end - - test "if the conn has no key set, it sets it to nil", %{conn: conn} do - conn = - conn - |> EnsureUserKeyPlug.call(%{}) - - assert Map.has_key?(conn.assigns, :user) - end -end diff --git a/test/pleroma/web/plugs/ensure_user_token_assigns_plug_test.exs b/test/pleroma/web/plugs/ensure_user_token_assigns_plug_test.exs new file mode 100644 index 000000000..9592820c7 --- /dev/null +++ b/test/pleroma/web/plugs/ensure_user_token_assigns_plug_test.exs @@ -0,0 +1,69 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.EnsureUserTokenAssignsPlugTest do + use Pleroma.Web.ConnCase, async: true + + import Pleroma.Factory + + alias Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug + + test "with :user assign set to a User record " <> + "and :token assign set to a Token belonging to this user, " <> + "it does nothing" do + %{conn: conn} = oauth_access(["read"]) + + ret_conn = EnsureUserTokenAssignsPlug.call(conn, %{}) + + assert conn == ret_conn + end + + test "with :user assign set to a User record " <> + "but :token assign not set or not a Token, " <> + "it assigns :token to `nil`", + %{conn: conn} do + user = insert(:user) + conn = assign(conn, :user, user) + + ret_conn = EnsureUserTokenAssignsPlug.call(conn, %{}) + + assert %{token: nil} = ret_conn.assigns + + ret_conn2 = + conn + |> assign(:token, 1) + |> EnsureUserTokenAssignsPlug.call(%{}) + + assert %{token: nil} = ret_conn2.assigns + end + + # Abnormal (unexpected) scenario + test "with :user assign set to a User record " <> + "but :token assign set to a Token NOT belonging to :user, " <> + "it drops auth info" do + %{conn: conn} = oauth_access(["read"]) + other_user = insert(:user) + + conn = assign(conn, :user, other_user) + + ret_conn = EnsureUserTokenAssignsPlug.call(conn, %{}) + + assert %{user: nil, token: nil} = ret_conn.assigns + end + + test "if :user assign is not set to a User record, it sets :user and :token to nil", %{ + conn: conn + } do + ret_conn = EnsureUserTokenAssignsPlug.call(conn, %{}) + + assert %{user: nil, token: nil} = ret_conn.assigns + + ret_conn2 = + conn + |> assign(:user, 1) + |> EnsureUserTokenAssignsPlug.call(%{}) + + assert %{user: nil, token: nil} = ret_conn2.assigns + end +end diff --git a/test/pleroma/web/plugs/frontend_static_plug_test.exs b/test/pleroma/web/plugs/frontend_static_plug_test.exs index f6f7d7bdb..8b7b022fc 100644 --- a/test/pleroma/web/plugs/frontend_static_plug_test.exs +++ b/test/pleroma/web/plugs/frontend_static_plug_test.exs @@ -4,6 +4,7 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do use Pleroma.Web.ConnCase + import Mock @dir "test/tmp/instance_static" @@ -53,4 +54,24 @@ test "overrides existing static files for the `pleroma/admin` path", %{conn: con index = get(conn, "/pleroma/admin/") assert html_response(index, 200) == "from frontend plug" end + + test "exclude invalid path", %{conn: conn} do + name = "pleroma-fe" + ref = "dist" + clear_config([:media_proxy, :enabled], true) + clear_config([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") + clear_config([:frontends, :primary], %{"name" => name, "ref" => ref}) + path = "#{@dir}/frontends/#{name}/#{ref}" + + File.mkdir_p!("#{path}/proxy/rr/ss") + File.write!("#{path}/proxy/rr/ss/Ek7w8WPVcAApOvN.jpg:large", "FB image") + + url = + Pleroma.Web.MediaProxy.encode_url("https://pbs.twimg.com/media/Ek7w8WPVcAApOvN.jpg:large") + + with_mock Pleroma.ReverseProxy, + call: fn _conn, _url, _opts -> %Plug.Conn{status: :success} end do + assert %Plug.Conn{status: :success} = get(conn, url) + end + end end diff --git a/test/pleroma/web/plugs/http_security_plug_test.exs b/test/pleroma/web/plugs/http_security_plug_test.exs index 2297e3dac..df2b5ebb3 100644 --- a/test/pleroma/web/plugs/http_security_plug_test.exs +++ b/test/pleroma/web/plugs/http_security_plug_test.exs @@ -5,7 +5,6 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlugTest do use Pleroma.Web.ConnCase - alias Pleroma.Config alias Plug.Conn describe "http security enabled" do diff --git a/test/pleroma/web/plugs/idempotency_plug_test.exs b/test/pleroma/web/plugs/idempotency_plug_test.exs index 4a7835993..ed8b3fc1a 100644 --- a/test/pleroma/web/plugs/idempotency_plug_test.exs +++ b/test/pleroma/web/plugs/idempotency_plug_test.exs @@ -3,7 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.IdempotencyPlugTest do - use ExUnit.Case, async: true + # Relies on Cachex, has to stay synchronous + use Pleroma.DataCase use Plug.Test alias Pleroma.Web.Plugs.IdempotencyPlug diff --git a/test/pleroma/web/plugs/legacy_authentication_plug_test.exs b/test/pleroma/web/plugs/legacy_authentication_plug_test.exs deleted file mode 100644 index 2016a31a8..000000000 --- a/test/pleroma/web/plugs/legacy_authentication_plug_test.exs +++ /dev/null @@ -1,82 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright ยฉ 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Plugs.LegacyAuthenticationPlugTest do - use Pleroma.Web.ConnCase - - import Pleroma.Factory - - alias Pleroma.User - alias Pleroma.Web.Plugs.LegacyAuthenticationPlug - alias Pleroma.Web.Plugs.OAuthScopesPlug - alias Pleroma.Web.Plugs.PlugHelper - - setup do - user = - insert(:user, - password: "password", - password_hash: - "$6$9psBWV8gxkGOZWBz$PmfCycChoxeJ3GgGzwvhlgacb9mUoZ.KUXNCssekER4SJ7bOK53uXrHNb2e4i8yPFgSKyzaW9CcmrDXWIEMtD1" - ) - - %{user: user} - end - - test "it does nothing if a user is assigned", %{conn: conn, user: user} do - conn = - conn - |> assign(:auth_credentials, %{username: "dude", password: "password"}) - |> assign(:auth_user, user) - |> assign(:user, %User{}) - - ret_conn = - conn - |> LegacyAuthenticationPlug.call(%{}) - - assert ret_conn == conn - end - - @tag :skip_on_mac - test "if `auth_user` is present and password is correct, " <> - "it authenticates the user, resets the password, marks OAuthScopesPlug as skipped", - %{ - conn: conn, - user: user - } do - conn = - conn - |> assign(:auth_credentials, %{username: "dude", password: "password"}) - |> assign(:auth_user, user) - - conn = LegacyAuthenticationPlug.call(conn, %{}) - - assert conn.assigns.user.id == user.id - assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) - end - - @tag :skip_on_mac - test "it does nothing if the password is wrong", %{ - conn: conn, - user: user - } do - conn = - conn - |> assign(:auth_credentials, %{username: "dude", password: "wrong_password"}) - |> assign(:auth_user, user) - - ret_conn = - conn - |> LegacyAuthenticationPlug.call(%{}) - - assert conn == ret_conn - end - - test "with no credentials or user it does nothing", %{conn: conn} do - ret_conn = - conn - |> LegacyAuthenticationPlug.call(%{}) - - assert ret_conn == conn - end -end diff --git a/test/pleroma/web/plugs/o_auth_plug_test.exs b/test/pleroma/web/plugs/o_auth_plug_test.exs index b9d722f76..1186cdb14 100644 --- a/test/pleroma/web/plugs/o_auth_plug_test.exs +++ b/test/pleroma/web/plugs/o_auth_plug_test.exs @@ -5,43 +5,49 @@ defmodule Pleroma.Web.Plugs.OAuthPlugTest do use Pleroma.Web.ConnCase, async: true + alias Pleroma.Helpers.AuthHelper + alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.Token.Strategy.Revoke alias Pleroma.Web.Plugs.OAuthPlug - import Pleroma.Factory + alias Plug.Session - @session_opts [ - store: :cookie, - key: "_test", - signing_salt: "cooldude" - ] + import Pleroma.Factory setup %{conn: conn} do user = insert(:user) - {:ok, %{token: token}} = Pleroma.Web.OAuth.Token.create(insert(:oauth_app), user) - %{user: user, token: token, conn: conn} + {:ok, oauth_token} = Token.create(insert(:oauth_app), user) + %{user: user, token: oauth_token, conn: conn} end - test "with valid token(uppercase), it assigns the user", %{conn: conn} = opts do + test "it does nothing if a user is assigned", %{conn: conn} do + conn = assign(conn, :user, %Pleroma.User{}) + ret_conn = OAuthPlug.call(conn, %{}) + + assert ret_conn == conn + end + + test "with valid token (uppercase) in auth header, it assigns the user", %{conn: conn} = opts do conn = conn - |> put_req_header("authorization", "BEARER #{opts[:token]}") + |> put_req_header("authorization", "BEARER #{opts[:token].token}") |> OAuthPlug.call(%{}) assert conn.assigns[:user] == opts[:user] end - test "with valid token(downcase), it assigns the user", %{conn: conn} = opts do + test "with valid token (downcase) in auth header, it assigns the user", %{conn: conn} = opts do conn = conn - |> put_req_header("authorization", "bearer #{opts[:token]}") + |> put_req_header("authorization", "bearer #{opts[:token].token}") |> OAuthPlug.call(%{}) assert conn.assigns[:user] == opts[:user] end - test "with valid token(downcase) in url parameters, it assigns the user", opts do + test "with valid token (downcase) in url parameters, it assigns the user", opts do conn = :get - |> build_conn("/?access_token=#{opts[:token]}") + |> build_conn("/?access_token=#{opts[:token].token}") |> put_req_header("content-type", "application/json") |> fetch_query_params() |> OAuthPlug.call(%{}) @@ -49,16 +55,16 @@ test "with valid token(downcase) in url parameters, it assigns the user", opts d assert conn.assigns[:user] == opts[:user] end - test "with valid token(downcase) in body parameters, it assigns the user", opts do + test "with valid token (downcase) in body parameters, it assigns the user", opts do conn = :post - |> build_conn("/api/v1/statuses", access_token: opts[:token], status: "test") + |> build_conn("/api/v1/statuses", access_token: opts[:token].token, status: "test") |> OAuthPlug.call(%{}) assert conn.assigns[:user] == opts[:user] end - test "with invalid token, it not assigns the user", %{conn: conn} do + test "with invalid token, it does not assign the user", %{conn: conn} do conn = conn |> put_req_header("authorization", "bearer TTTTT") @@ -67,14 +73,56 @@ test "with invalid token, it not assigns the user", %{conn: conn} do refute conn.assigns[:user] end - test "when token is missed but token in session, it assigns the user", %{conn: conn} = opts do - conn = - conn - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> put_session(:oauth_token, opts[:token]) - |> OAuthPlug.call(%{}) + describe "with :oauth_token in session, " do + setup %{token: oauth_token, conn: conn} do + session_opts = [ + store: :cookie, + key: "_test", + signing_salt: "cooldude" + ] - assert conn.assigns[:user] == opts[:user] + conn = + conn + |> Session.call(Session.init(session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(oauth_token.token) + + %{conn: conn} + end + + test "if session-stored token matches a valid OAuth token, assigns :user and :token", %{ + conn: conn, + user: user, + token: oauth_token + } do + conn = OAuthPlug.call(conn, %{}) + + assert conn.assigns.user && conn.assigns.user.id == user.id + assert conn.assigns.token && conn.assigns.token.id == oauth_token.id + end + + test "if session-stored token matches an expired OAuth token, does nothing", %{ + conn: conn, + token: oauth_token + } do + expired_valid_until = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600 * 24, :second) + + oauth_token + |> Ecto.Changeset.change(valid_until: expired_valid_until) + |> Pleroma.Repo.update() + + ret_conn = OAuthPlug.call(conn, %{}) + assert ret_conn == conn + end + + test "if session-stored token matches a revoked OAuth token, does nothing", %{ + conn: conn, + token: oauth_token + } do + Revoke.revoke(oauth_token) + + ret_conn = OAuthPlug.call(conn, %{}) + assert ret_conn == conn + end end end diff --git a/test/pleroma/web/plugs/session_authentication_plug_test.exs b/test/pleroma/web/plugs/session_authentication_plug_test.exs deleted file mode 100644 index 2b4d5bc0c..000000000 --- a/test/pleroma/web/plugs/session_authentication_plug_test.exs +++ /dev/null @@ -1,63 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright ยฉ 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Plugs.SessionAuthenticationPlugTest do - use Pleroma.Web.ConnCase, async: true - - alias Pleroma.User - alias Pleroma.Web.Plugs.SessionAuthenticationPlug - - setup %{conn: conn} do - session_opts = [ - store: :cookie, - key: "_test", - signing_salt: "cooldude" - ] - - conn = - conn - |> Plug.Session.call(Plug.Session.init(session_opts)) - |> fetch_session - |> assign(:auth_user, %User{id: 1}) - - %{conn: conn} - end - - test "it does nothing if a user is assigned", %{conn: conn} do - conn = - conn - |> assign(:user, %User{}) - - ret_conn = - conn - |> SessionAuthenticationPlug.call(%{}) - - assert ret_conn == conn - end - - test "if the auth_user has the same id as the user_id in the session, it assigns the user", %{ - conn: conn - } do - conn = - conn - |> put_session(:user_id, conn.assigns.auth_user.id) - |> SessionAuthenticationPlug.call(%{}) - - assert conn.assigns.user == conn.assigns.auth_user - end - - test "if the auth_user has a different id as the user_id in the session, it does nothing", %{ - conn: conn - } do - conn = - conn - |> put_session(:user_id, -1) - - ret_conn = - conn - |> SessionAuthenticationPlug.call(%{}) - - assert ret_conn == conn - end -end diff --git a/test/pleroma/web/plugs/set_user_session_id_plug_test.exs b/test/pleroma/web/plugs/set_user_session_id_plug_test.exs index a89b5628f..21417d0e7 100644 --- a/test/pleroma/web/plugs/set_user_session_id_plug_test.exs +++ b/test/pleroma/web/plugs/set_user_session_id_plug_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Web.Plugs.SetUserSessionIdPlugTest do use Pleroma.Web.ConnCase, async: true - alias Pleroma.User + alias Pleroma.Helpers.AuthHelper alias Pleroma.Web.Plugs.SetUserSessionIdPlug setup %{conn: conn} do @@ -18,28 +18,26 @@ defmodule Pleroma.Web.Plugs.SetUserSessionIdPlugTest do conn = conn |> Plug.Session.call(Plug.Session.init(session_opts)) - |> fetch_session + |> fetch_session() %{conn: conn} end test "doesn't do anything if the user isn't set", %{conn: conn} do - ret_conn = - conn - |> SetUserSessionIdPlug.call(%{}) + ret_conn = SetUserSessionIdPlug.call(conn, %{}) assert ret_conn == conn end - test "sets the user_id in the session to the user id of the user assign", %{conn: conn} do - Code.ensure_compiled(Pleroma.User) + test "sets session token basing on :token assign", %{conn: conn} do + %{user: user, token: oauth_token} = oauth_access(["read"]) - conn = + ret_conn = conn - |> assign(:user, %User{id: 1}) + |> assign(:user, user) + |> assign(:token, oauth_token) |> SetUserSessionIdPlug.call(%{}) - id = get_session(conn, :user_id) - assert id == 1 + assert AuthHelper.get_session_token(ret_conn) == oauth_token.token end end diff --git a/test/pleroma/web/plugs/uploaded_media_plug_test.exs b/test/pleroma/web/plugs/uploaded_media_plug_test.exs index 7c8313121..bae9208ec 100644 --- a/test/pleroma/web/plugs/uploaded_media_plug_test.exs +++ b/test/pleroma/web/plugs/uploaded_media_plug_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.UploadedMediaPlugTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Upload defp upload_file(context) do diff --git a/test/pleroma/web/plugs/user_enabled_plug_test.exs b/test/pleroma/web/plugs/user_enabled_plug_test.exs index 71c56f03a..e9c9e5f3e 100644 --- a/test/pleroma/web/plugs/user_enabled_plug_test.exs +++ b/test/pleroma/web/plugs/user_enabled_plug_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.UserEnabledPlugTest do - use Pleroma.Web.ConnCase, async: true + use Pleroma.Web.ConnCase alias Pleroma.Web.Plugs.UserEnabledPlug import Pleroma.Factory diff --git a/test/pleroma/web/preload/providers/instance_test.exs b/test/pleroma/web/preload/providers/instance_test.exs index 8493f2a94..6033899b0 100644 --- a/test/pleroma/web/preload/providers/instance_test.exs +++ b/test/pleroma/web/preload/providers/instance_test.exs @@ -50,7 +50,7 @@ test "it renders the frontend configurations", %{ "/api/pleroma/frontend_configurations" => fe_configs } do assert %{ - pleroma_fe: %{background: "/images/city.jpg", logo: "/static/logo.png"} + pleroma_fe: %{background: "/images/city.jpg", logo: "/static/logo.svg"} } = fe_configs end end diff --git a/test/pleroma/web/preload/providers/user_test.exs b/test/pleroma/web/preload/providers/user_test.exs index 83f065e27..6be03af79 100644 --- a/test/pleroma/web/preload/providers/user_test.exs +++ b/test/pleroma/web/preload/providers/user_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Preload.Providers.UserTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Web.Preload.Providers.User diff --git a/test/pleroma/web/push/impl_test.exs b/test/pleroma/web/push/impl_test.exs index 7d8cc999a..326a67963 100644 --- a/test/pleroma/web/push/impl_test.exs +++ b/test/pleroma/web/push/impl_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Push.ImplTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory @@ -184,6 +184,24 @@ test "renders title and body for like activity" do "New Favorite" end + test "renders title and body for pleroma:emoji_reaction activity" do + user = insert(:user, nickname: "Bob") + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "This post is a really good post!" + }) + + {:ok, activity} = CommonAPI.react_with_emoji(activity.id, user, "๐Ÿ‘") + object = Object.normalize(activity) + + assert Impl.format_body(%{activity: activity, type: "pleroma:emoji_reaction"}, user, object) == + "@Bob reacted with ๐Ÿ‘" + + assert Impl.format_title(%{activity: activity, type: "pleroma:emoji_reaction"}) == + "New Reaction" + end + test "renders title for create activity with direct visibility" do user = insert(:user, nickname: "Bob") diff --git a/test/pleroma/web/rel_me_test.exs b/test/pleroma/web/rel_me_test.exs index 65255916d..811cb0893 100644 --- a/test/pleroma/web/rel_me_test.exs +++ b/test/pleroma/web/rel_me_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RelMeTest do - use ExUnit.Case + use Pleroma.DataCase setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) diff --git a/test/pleroma/web/rich_media/parser/ttl/aws_signed_url_test.exs b/test/pleroma/web/rich_media/parser/ttl/aws_signed_url_test.exs index 2f17bebd7..242521138 100644 --- a/test/pleroma/web/rich_media/parser/ttl/aws_signed_url_test.exs +++ b/test/pleroma/web/rich_media/parser/ttl/aws_signed_url_test.exs @@ -3,7 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrlTest do - use ExUnit.Case, async: true + # Relies on Cachex, needs to be synchronous + use Pleroma.DataCase test "s3 signed url is parsed correct for expiration time" do url = "https://pleroma.social/amz" diff --git a/test/pleroma/web/static_fe/static_fe_controller_test.exs b/test/pleroma/web/static_fe/static_fe_controller_test.exs index f819a1e52..19506f1d8 100644 --- a/test/pleroma/web/static_fe/static_fe_controller_test.exs +++ b/test/pleroma/web/static_fe/static_fe_controller_test.exs @@ -6,14 +6,12 @@ defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do use Pleroma.Web.ConnCase alias Pleroma.Activity - alias Pleroma.Config alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI import Pleroma.Factory setup_all do: clear_config([:static_fe, :enabled], true) - setup do: clear_config([:instance, :federating], true) setup %{conn: conn} do conn = put_req_header(conn, "accept", "text/html") @@ -74,8 +72,27 @@ test "pagination, page 2", %{conn: conn, user: user} do refute html =~ ">test29<" end - test "it requires authentication if instance is NOT federating", %{conn: conn, user: user} do - ensure_federating_or_authenticated(conn, "/users/#{user.nickname}", user) + test "does not require authentication on non-federating instances", %{ + conn: conn, + user: user + } do + clear_config([:instance, :federating], false) + + conn = get(conn, "/users/#{user.nickname}") + + assert html_response(conn, 200) =~ user.nickname + end + + test "returns 404 for local user with `restrict_unauthenticated/profiles/local` setting", %{ + conn: conn + } do + clear_config([:restrict_unauthenticated, :profiles, :local], true) + + local_user = insert(:user, local: true) + + conn + |> get("/users/#{local_user.nickname}") + |> html_response(404) end end @@ -187,10 +204,28 @@ test "302 for remote cached status", %{conn: conn, user: user} do assert html_response(conn, 302) =~ "redirected" end - test "it requires authentication if instance is NOT federating", %{conn: conn, user: user} do + test "does not require authentication on non-federating instances", %{ + conn: conn, + user: user + } do + clear_config([:instance, :federating], false) + {:ok, activity} = CommonAPI.post(user, %{status: "testing a thing!"}) - ensure_federating_or_authenticated(conn, "/notice/#{activity.id}", user) + conn = get(conn, "/notice/#{activity.id}") + + assert html_response(conn, 200) =~ "testing a thing!" + end + + test "returns 404 for local public activity with `restrict_unauthenticated/activities/local` setting", + %{conn: conn, user: user} do + clear_config([:restrict_unauthenticated, :activities, :local], true) + + {:ok, activity} = CommonAPI.post(user, %{status: "testing a thing!"}) + + conn + |> get("/notice/#{activity.id}") + |> html_response(404) end end end diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index 185724a9f..ad66ddc9d 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -29,6 +29,14 @@ test "allows public" do assert {:ok, "public:local:media"} = Streamer.get_topic("public:local:media", nil, nil) end + test "allows instance streams" do + assert {:ok, "public:remote:lain.com"} = + Streamer.get_topic("public:remote", nil, nil, %{"instance" => "lain.com"}) + + assert {:ok, "public:remote:media:lain.com"} = + Streamer.get_topic("public:remote:media", nil, nil, %{"instance" => "lain.com"}) + end + test "allows hashtag streams" do assert {:ok, "hashtag:cofe"} = Streamer.get_topic("hashtag", nil, nil, %{"tag" => "cofe"}) end @@ -214,7 +222,7 @@ test "it streams boosts of mastodon user in the 'user' stream", %{ data = File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("object", activity.data["object"]) |> Map.put("actor", user.ap_id) @@ -255,7 +263,9 @@ test "it sends chat messages to the 'user:pleroma_chat' stream", %{ } do other_user = insert(:user) - {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") + {:ok, create_activity} = + CommonAPI.post_chat_message(other_user, user, "hey cirno", idempotency_key: "123") + object = Object.normalize(create_activity, false) chat = Chat.get(user.id, other_user.ap_id) cm_ref = MessageReference.for_chat_and_object(chat, object) @@ -393,6 +403,67 @@ test "it sends follow activities to the 'user:notification' stream", %{ assert notif.activity.id == follow_activity.id refute Streamer.filtered_by_user?(user, notif) end + + test "it sends follow relationships updates to the 'user' stream", %{ + user: user, + token: oauth_token + } do + user_id = user.id + user_url = user.ap_id + other_user = insert(:user) + other_user_id = other_user.id + + body = + File.read!("test/fixtures/users_mock/localhost.json") + |> String.replace("{{nickname}}", user.nickname) + |> Jason.encode!() + + Tesla.Mock.mock_global(fn + %{method: :get, url: ^user_url} -> + %Tesla.Env{status: 200, body: body} + end) + + Streamer.get_topic_and_add_socket("user", user, oauth_token) + {:ok, _follower, _followed, _follow_activity} = CommonAPI.follow(user, other_user) + + assert_receive {:text, event} + + assert %{"event" => "pleroma:follow_relationships_update", "payload" => payload} = + Jason.decode!(event) + + assert %{ + "follower" => %{ + "follower_count" => 0, + "following_count" => 0, + "id" => ^user_id + }, + "following" => %{ + "follower_count" => 0, + "following_count" => 0, + "id" => ^other_user_id + }, + "state" => "follow_pending" + } = Jason.decode!(payload) + + assert_receive {:text, event} + + assert %{"event" => "pleroma:follow_relationships_update", "payload" => payload} = + Jason.decode!(event) + + assert %{ + "follower" => %{ + "follower_count" => 0, + "following_count" => 1, + "id" => ^user_id + }, + "following" => %{ + "follower_count" => 1, + "following_count" => 0, + "id" => ^other_user_id + }, + "state" => "follow_accept" + } = Jason.decode!(payload) + end end describe "public streams" do @@ -553,7 +624,7 @@ test "it doesn't send unwanted DMs to list", %{user: user_a, token: user_a_token user_b = insert(:user) user_c = insert(:user) - {:ok, user_a} = User.follow(user_a, user_b) + {:ok, user_a, user_b} = User.follow(user_a, user_b) {:ok, list} = List.create("Test", user_a) {:ok, list} = List.follow(list, user_b) @@ -589,7 +660,7 @@ test "it doesn't send unwanted private posts to list", %{user: user_a, token: us test "it sends wanted private posts to list", %{user: user_a, token: user_a_token} do user_b = insert(:user) - {:ok, user_a} = User.follow(user_a, user_b) + {:ok, user_a, user_b} = User.follow(user_a, user_b) {:ok, list} = List.create("Test", user_a) {:ok, list} = List.follow(list, user_b) diff --git a/test/pleroma/web/twitter_api/controller_test.exs b/test/pleroma/web/twitter_api/controller_test.exs index 464d0ea2e..b3ca67637 100644 --- a/test/pleroma/web/twitter_api/controller_test.exs +++ b/test/pleroma/web/twitter_api/controller_test.exs @@ -3,11 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.ControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true - alias Pleroma.Builders.ActivityBuilder alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.CommonAPI alias Pleroma.Web.OAuth.Token import Pleroma.Factory @@ -36,22 +36,20 @@ test "with credentials, with params" do other_user = insert(:user) {:ok, _activity} = - ActivityBuilder.insert(%{"to" => [current_user.ap_id]}, %{user: other_user}) + CommonAPI.post(other_user, %{ + status: "Hey @#{current_user.nickname}" + }) response_conn = conn - |> assign(:user, current_user) |> get("/api/v1/notifications") - [notification] = response = json_response(response_conn, 200) - - assert length(response) == 1 + [notification] = json_response(response_conn, 200) assert notification["pleroma"]["is_seen"] == false response_conn = conn - |> assign(:user, current_user) |> post("/api/qvitter/statuses/notifications/read", %{"latest_id" => notification["id"]}) [notification] = response = json_response(response_conn, 200) diff --git a/test/pleroma/web/twitter_api/password_controller_test.exs b/test/pleroma/web/twitter_api/password_controller_test.exs index a5e9e2178..c1f5bc5c7 100644 --- a/test/pleroma/web/twitter_api/password_controller_test.exs +++ b/test/pleroma/web/twitter_api/password_controller_test.exs @@ -31,9 +31,47 @@ test "it shows password reset form", %{conn: conn} do assert response =~ "

    Password Reset for #{user.nickname}

    " end + + test "it returns an error when the token has expired", %{conn: conn} do + clear_config([:instance, :password_reset_token_validity], 0) + + user = insert(:user) + {:ok, token} = PasswordResetToken.create_token(user) + {:ok, token} = time_travel(token, -2) + + response = + conn + |> get("/api/pleroma/password_reset/#{token.token}") + |> html_response(:ok) + + assert response =~ "

    Invalid Token

    " + end end describe "POST /api/pleroma/password_reset" do + test "it fails for an expired token", %{conn: conn} do + clear_config([:instance, :password_reset_token_validity], 0) + + user = insert(:user) + {:ok, token} = PasswordResetToken.create_token(user) + {:ok, token} = time_travel(token, -2) + {:ok, _access_token} = Token.create(insert(:oauth_app), user, %{}) + + params = %{ + "password" => "test", + password_confirmation: "test", + token: token.token + } + + response = + conn + |> assign(:user, user) + |> post("/api/pleroma/password_reset", %{data: params}) + |> html_response(:ok) + + refute response =~ "

    Password changed!

    " + end + test "it returns HTTP 200", %{conn: conn} do user = insert(:user) {:ok, token} = PasswordResetToken.create_token(user) diff --git a/test/pleroma/web/twitter_api/remote_follow_controller_test.exs b/test/pleroma/web/twitter_api/remote_follow_controller_test.exs index 3852c7ce9..dfe5b02be 100644 --- a/test/pleroma/web/twitter_api/remote_follow_controller_test.exs +++ b/test/pleroma/web/twitter_api/remote_follow_controller_test.exs @@ -5,7 +5,6 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do use Pleroma.Web.ConnCase - alias Pleroma.Config alias Pleroma.MFA alias Pleroma.MFA.TOTP alias Pleroma.User @@ -15,18 +14,27 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do import Pleroma.Factory import Ecto.Query - setup do - Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) - :ok - end - setup_all do: clear_config([:instance, :federating], true) - setup do: clear_config([:instance]) - setup do: clear_config([:frontend_configurations, :pleroma_fe]) setup do: clear_config([:user, :deny_follow_blocked]) describe "GET /ostatus_subscribe - remote_follow/2" do test "adds status to pleroma instance if the `acct` is a status", %{conn: conn} do + Tesla.Mock.mock(fn + %{method: :get, url: "https://mastodon.social/users/emelie/statuses/101849165031453009"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: File.read!("test/fixtures/tesla_mock/status.emelie.json") + } + + %{method: :get, url: "https://mastodon.social/users/emelie"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: File.read!("test/fixtures/tesla_mock/emelie.json") + } + end) + assert conn |> get( remote_follow_path(conn, :follow, %{ @@ -37,6 +45,15 @@ test "adds status to pleroma instance if the `acct` is a status", %{conn: conn} end test "show follow account page if the `acct` is a account link", %{conn: conn} do + Tesla.Mock.mock(fn + %{method: :get, url: "https://mastodon.social/users/emelie"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: File.read!("test/fixtures/tesla_mock/emelie.json") + } + end) + response = conn |> get(remote_follow_path(conn, :follow, %{acct: "https://mastodon.social/users/emelie"})) @@ -46,6 +63,15 @@ test "show follow account page if the `acct` is a account link", %{conn: conn} d end test "show follow page if the `acct` is a account link", %{conn: conn} do + Tesla.Mock.mock(fn + %{method: :get, url: "https://mastodon.social/users/emelie"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: File.read!("test/fixtures/tesla_mock/emelie.json") + } + end) + user = insert(:user) response = @@ -57,7 +83,14 @@ test "show follow page if the `acct` is a account link", %{conn: conn} do assert response =~ "Remote follow" end - test "show follow page with error when user cannot fecth by `acct` link", %{conn: conn} do + test "show follow page with error when user can not be fetched by `acct` link", %{conn: conn} do + Tesla.Mock.mock(fn + %{method: :get, url: "https://mastodon.social/users/not_found"} -> + %Tesla.Env{ + status: 404 + } + end) + user = insert(:user) assert capture_log(fn -> diff --git a/test/pleroma/web/twitter_api/twitter_api_test.exs b/test/pleroma/web/twitter_api/twitter_api_test.exs index 20a45cb6f..5586a9a13 100644 --- a/test/pleroma/web/twitter_api/twitter_api_test.exs +++ b/test/pleroma/web/twitter_api/twitter_api_test.exs @@ -80,13 +80,9 @@ test "it sends confirmation email if :account_activation_required is specified i end test "it sends an admin email if :account_approval_required is specified in instance config" do - admin = insert(:user, is_admin: true) - setting = Pleroma.Config.get([:instance, :account_approval_required]) + clear_config([:instance, :account_approval_required], true) - unless setting do - Pleroma.Config.put([:instance, :account_approval_required], true) - on_exit(fn -> Pleroma.Config.put([:instance, :account_approval_required], setting) end) - end + admin = insert(:user, is_admin: true) data = %{ :username => "lain", @@ -103,15 +99,24 @@ test "it sends an admin email if :account_approval_required is specified in inst assert user.approval_pending - email = Pleroma.Emails.AdminEmail.new_unapproved_registration(admin, user) + user_email = Pleroma.Emails.UserEmail.approval_pending_email(user) + admin_email = Pleroma.Emails.AdminEmail.new_unapproved_registration(admin, user) notify_email = Pleroma.Config.get([:instance, :notify_email]) instance_name = Pleroma.Config.get([:instance, :name]) + # User approval email + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: {user.name, user.email}, + html_body: user_email.html_body + ) + + # Admin email Swoosh.TestAssertions.assert_email_sent( from: {instance_name, notify_email}, to: {admin.name, admin.email}, - html_body: email.html_body + html_body: admin_email.html_body ) end @@ -423,10 +428,4 @@ test "it returns the error on registration problems" do assert is_binary(error) refute User.get_cached_by_nickname("lain") end - - setup do - Supervisor.terminate_child(Pleroma.Supervisor, Cachex) - Supervisor.restart_child(Pleroma.Supervisor, Cachex) - :ok - end end diff --git a/test/pleroma/web/uploader_controller_test.exs b/test/pleroma/web/uploader_controller_test.exs index 21e518236..00f9e72ec 100644 --- a/test/pleroma/web/uploader_controller_test.exs +++ b/test/pleroma/web/uploader_controller_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.UploaderControllerTest do - use Pleroma.Web.ConnCase + use Pleroma.Web.ConnCase, async: true alias Pleroma.Uploaders.Uploader describe "callback/2" do diff --git a/test/pleroma/web/web_finger_test.exs b/test/pleroma/web/web_finger_test.exs index 96fc0bbaa..cdb84ae1e 100644 --- a/test/pleroma/web/web_finger_test.exs +++ b/test/pleroma/web/web_finger_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.WebFingerTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.Web.WebFinger import Pleroma.Factory import Tesla.Mock @@ -56,12 +56,13 @@ test "returns the ActivityPub actor URI for an ActivityPub user" do {:ok, _data} = WebFinger.finger(user) end - test "returns the ActivityPub actor URI for an ActivityPub user with the ld+json mimetype" do + test "returns the ActivityPub actor URI and subscribe address for an ActivityPub user with the ld+json mimetype" do user = "kaniini@gerzilla.de" {:ok, data} = WebFinger.finger(user) assert data["ap_id"] == "https://gerzilla.de/channel/kaniini" + assert data["subscribe_address"] == "https://gerzilla.de/follow?f=&url={uri}" end test "it work for AP-only user" do diff --git a/test/pleroma/workers/cron/new_users_digest_worker_test.exs b/test/pleroma/workers/cron/new_users_digest_worker_test.exs index 129534cb1..e00ed6745 100644 --- a/test/pleroma/workers/cron/new_users_digest_worker_test.exs +++ b/test/pleroma/workers/cron/new_users_digest_worker_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.Cron.NewUsersDigestWorkerTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true import Pleroma.Factory alias Pleroma.Tests.ObanHelpers @@ -28,7 +28,7 @@ test "it sends new users digest emails" do assert email.html_body =~ user.nickname assert email.html_body =~ user2.nickname assert email.html_body =~ "cofe" - assert email.html_body =~ "#{Pleroma.Web.Endpoint.url()}/static/logo.png" + assert email.html_body =~ "#{Pleroma.Web.Endpoint.url()}/static/logo.svg" end test "it doesn't fail when admin has no email" do diff --git a/test/pleroma/xml_builder_test.exs b/test/pleroma/xml_builder_test.exs index 059384c34..a4c73359d 100644 --- a/test/pleroma/xml_builder_test.exs +++ b/test/pleroma/xml_builder_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.XmlBuilderTest do - use Pleroma.DataCase + use Pleroma.DataCase, async: true alias Pleroma.XmlBuilder test "Build a basic xml string from a tuple" do diff --git a/test/support/cachex_proxy.ex b/test/support/cachex_proxy.ex new file mode 100644 index 000000000..e296b5c6a --- /dev/null +++ b/test/support/cachex_proxy.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.CachexProxy do + @behaviour Pleroma.Caching + + @impl true + defdelegate get!(cache, key), to: Cachex + + @impl true + defdelegate stream!(cache, key), to: Cachex + + @impl true + defdelegate put(cache, key, value, options), to: Cachex + + @impl true + defdelegate put(cache, key, value), to: Cachex + + @impl true + defdelegate get_and_update(cache, key, func), to: Cachex + + @impl true + defdelegate get(cache, key), to: Cachex + + @impl true + defdelegate fetch!(cache, key, func), to: Cachex + + @impl true + defdelegate expire_at(cache, str, num), to: Cachex + + @impl true + defdelegate exists?(cache, key), to: Cachex + + @impl true + defdelegate del(cache, key), to: Cachex + + @impl true + defdelegate execute!(cache, func), to: Cachex +end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex index d63a0f06b..f4696adb3 100644 --- a/test/support/channel_case.ex +++ b/test/support/channel_case.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.ChannelCase do using do quote do # Import conveniences for testing with channels - use Phoenix.ChannelTest + import Phoenix.ChannelTest use Pleroma.Tests.Helpers # The default endpoint for testing @@ -33,8 +33,14 @@ defmodule Pleroma.Web.ChannelCase do setup tags do :ok = Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo) - unless tags[:async] do + if tags[:async] do + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) + Mox.set_mox_private() + else Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, {:shared, self()}) + Mox.stub_with(Pleroma.CachexMock, Pleroma.CachexProxy) + Mox.set_mox_global() + Pleroma.DataCase.clear_cachex() end :ok diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 7ef681258..02f49c590 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -22,7 +22,8 @@ defmodule Pleroma.Web.ConnCase do using do quote do # Import conveniences for testing with connections - use Phoenix.ConnTest + import Plug.Conn + import Phoenix.ConnTest use Pleroma.Tests.Helpers import Pleroma.Web.Router.Helpers @@ -111,38 +112,20 @@ defp json_response_and_validate_schema( defp json_response_and_validate_schema(conn, _status) do flunk("Response schema not found for #{conn.method} #{conn.request_path} #{conn.status}") end - - defp ensure_federating_or_authenticated(conn, url, user) do - initial_setting = Config.get([:instance, :federating]) - on_exit(fn -> Config.put([:instance, :federating], initial_setting) end) - - Config.put([:instance, :federating], false) - - conn - |> get(url) - |> response(403) - - conn - |> assign(:user, user) - |> get(url) - |> response(200) - - Config.put([:instance, :federating], true) - - conn - |> get(url) - |> response(200) - end end end setup tags do - Cachex.clear(:user_cache) - Cachex.clear(:object_cache) :ok = Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo) - unless tags[:async] do + if tags[:async] do + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) + Mox.set_mox_private() + else Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, {:shared, self()}) + Mox.stub_with(Pleroma.CachexMock, Pleroma.CachexProxy) + Mox.set_mox_global() + Pleroma.DataCase.clear_cachex() end if tags[:needs_streamer] do @@ -153,6 +136,8 @@ defp ensure_federating_or_authenticated(conn, url, user) do }) end + Pleroma.DataCase.stub_pipeline() + {:ok, conn: Phoenix.ConnTest.build_conn()} end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index d5456521c..5c657c1d9 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -45,13 +45,34 @@ defp oauth_access(scopes, opts \\ []) do end end + def clear_cachex do + Pleroma.Supervisor + |> Supervisor.which_children() + |> Enum.each(fn + {name, _, _, [Cachex]} -> + name + |> to_string + |> String.trim_leading("cachex_") + |> Kernel.<>("_cache") + |> String.to_existing_atom() + |> Cachex.clear() + + _ -> + nil + end) + end + setup tags do - Cachex.clear(:user_cache) - Cachex.clear(:object_cache) :ok = Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo) - unless tags[:async] do + if tags[:async] do + Mox.stub_with(Pleroma.CachexMock, Pleroma.NullCache) + Mox.set_mox_private() + else Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, {:shared, self()}) + Mox.stub_with(Pleroma.CachexMock, Pleroma.CachexProxy) + Mox.set_mox_global() + clear_cachex() end if tags[:needs_streamer] do @@ -62,9 +83,25 @@ defp oauth_access(scopes, opts \\ []) do }) end + stub_pipeline() + :ok end + def stub_pipeline do + Mox.stub_with(Pleroma.Web.ActivityPub.SideEffectsMock, Pleroma.Web.ActivityPub.SideEffects) + + Mox.stub_with( + Pleroma.Web.ActivityPub.ObjectValidatorMock, + Pleroma.Web.ActivityPub.ObjectValidator + ) + + Mox.stub_with(Pleroma.Web.ActivityPub.MRFMock, Pleroma.Web.ActivityPub.MRF) + Mox.stub_with(Pleroma.Web.ActivityPub.ActivityPubMock, Pleroma.Web.ActivityPub.ActivityPub) + Mox.stub_with(Pleroma.Web.FederatorMock, Pleroma.Web.Federator) + Mox.stub_with(Pleroma.ConfigMock, Pleroma.Config) + end + def ensure_local_uploader(context) do test_uploader = Map.get(context, :uploader, Pleroma.Uploaders.Local) uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) diff --git a/test/support/factory.ex b/test/support/factory.ex index fb82be0c4..8eb07dc3c 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -24,14 +24,14 @@ def conversation_factory do } end - def user_factory do + def user_factory(attrs \\ %{}) do user = %User{ name: sequence(:name, &"Test ใƒ†ใ‚นใƒˆ User #{&1}"), email: sequence(:email, &"user#{&1}@example.com"), nickname: sequence(:nickname, &"nick#{&1}"), password_hash: Pbkdf2.hash_pwd_salt("test"), bio: sequence(:bio, &"Tester Number #{&1}"), - discoverable: true, + is_discoverable: true, last_digest_emailed_at: NaiveDateTime.utc_now(), last_refreshed_at: NaiveDateTime.utc_now(), notification_settings: %Pleroma.User.NotificationSetting{}, @@ -39,13 +39,29 @@ def user_factory do ap_enabled: true } - %{ - user - | ap_id: User.ap_id(user), - follower_address: User.ap_followers(user), - following_address: User.ap_following(user), - raw_bio: user.bio - } + urls = + if attrs[:local] == false do + base_domain = Enum.random(["domain1.com", "domain2.com", "domain3.com"]) + + ap_id = "https://#{base_domain}/users/#{user.nickname}" + + %{ + ap_id: ap_id, + follower_address: ap_id <> "/followers", + following_address: ap_id <> "/following" + } + else + %{ + ap_id: User.ap_id(user), + follower_address: User.ap_followers(user), + following_address: User.ap_following(user) + } + end + + user + |> Map.put(:raw_bio, user.bio) + |> Map.merge(urls) + |> merge_attributes(attrs) end def user_relationship_factory(attrs \\ %{}) do diff --git a/test/support/helpers.ex b/test/support/helpers.ex index ecd4b1e18..15e8cbd9d 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -55,6 +55,14 @@ defmacro __using__(_opts) do clear_config: 2 ] + def time_travel(entity, seconds) do + new_time = NaiveDateTime.add(entity.inserted_at, seconds) + + entity + |> Ecto.Changeset.change(%{inserted_at: new_time, updated_at: new_time}) + |> Pleroma.Repo.update() + end + def to_datetime(%NaiveDateTime{} = naive_datetime) do naive_datetime |> DateTime.from_naive!("Etc/UTC") @@ -85,8 +93,8 @@ def render_json(view, template, assigns) do assigns = Map.new(assigns) view.render(template, assigns) - |> Poison.encode!() - |> Poison.decode!() + |> Jason.encode!() + |> Jason.decode!() end def stringify_keys(nil), do: nil diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index cb022333f..93464ebff 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -5,6 +5,8 @@ defmodule HttpRequestMock do require Logger + def activitypub_object_headers, do: [{"content-type", "application/activity+json"}] + def request( %Tesla.Env{ url: url, @@ -34,7 +36,8 @@ def get("https://osada.macgirvin.com/channel/mike", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/https___osada.macgirvin.com_channel_mike.json") + body: File.read!("test/fixtures/tesla_mock/https___osada.macgirvin.com_channel_mike.json"), + headers: activitypub_object_headers() }} end @@ -42,7 +45,8 @@ def get("https://shitposter.club/users/moonman", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/moonman@shitposter.club.json") + body: File.read!("test/fixtures/tesla_mock/moonman@shitposter.club.json"), + headers: activitypub_object_headers() }} end @@ -50,7 +54,8 @@ def get("https://mastodon.social/users/emelie/statuses/101849165031453009", _, _ {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/status.emelie.json") + body: File.read!("test/fixtures/tesla_mock/status.emelie.json"), + headers: activitypub_object_headers() }} end @@ -66,7 +71,8 @@ def get("https://mastodon.social/users/emelie", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/emelie.json") + body: File.read!("test/fixtures/tesla_mock/emelie.json"), + headers: activitypub_object_headers() }} end @@ -78,7 +84,8 @@ def get("https://mastodon.sdf.org/users/rinpatch", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/rinpatch.json") + body: File.read!("test/fixtures/tesla_mock/rinpatch.json"), + headers: activitypub_object_headers() }} end @@ -86,7 +93,8 @@ def get("https://patch.cx/objects/tesla_mock/poll_attachment", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/poll_attachment.json") + body: File.read!("test/fixtures/tesla_mock/poll_attachment.json"), + headers: activitypub_object_headers() }} end @@ -99,7 +107,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/webfinger_emelie.json") + body: File.read!("test/fixtures/tesla_mock/webfinger_emelie.json"), + headers: activitypub_object_headers() }} end @@ -112,7 +121,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/mike@osada.macgirvin.com.json") + body: File.read!("test/fixtures/tesla_mock/mike@osada.macgirvin.com.json"), + headers: activitypub_object_headers() }} end @@ -190,7 +200,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/lucifermysticus.json") + body: File.read!("test/fixtures/tesla_mock/lucifermysticus.json"), + headers: activitypub_object_headers() }} end @@ -198,7 +209,8 @@ def get("https://prismo.news/@mxb", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/https___prismo.news__mxb.json") + body: File.read!("test/fixtures/tesla_mock/https___prismo.news__mxb.json"), + headers: activitypub_object_headers() }} end @@ -211,7 +223,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/kaniini@hubzilla.example.org.json") + body: File.read!("test/fixtures/tesla_mock/kaniini@hubzilla.example.org.json"), + headers: activitypub_object_headers() }} end @@ -219,7 +232,8 @@ def get("https://niu.moe/users/rye", _, _, [{"accept", "application/activity+jso {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/rye.json") + body: File.read!("test/fixtures/tesla_mock/rye.json"), + headers: activitypub_object_headers() }} end @@ -227,7 +241,8 @@ def get("https://n1u.moe/users/rye", _, _, [{"accept", "application/activity+jso {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/rye.json") + body: File.read!("test/fixtures/tesla_mock/rye.json"), + headers: activitypub_object_headers() }} end @@ -246,7 +261,8 @@ def get("https://puckipedia.com/", _, _, [{"accept", "application/activity+json" {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/puckipedia.com.json") + body: File.read!("test/fixtures/tesla_mock/puckipedia.com.json"), + headers: activitypub_object_headers() }} end @@ -254,7 +270,8 @@ def get("https://peertube.moe/accounts/7even", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/7even.json") + body: File.read!("test/fixtures/tesla_mock/7even.json"), + headers: activitypub_object_headers() }} end @@ -262,7 +279,8 @@ def get("https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/peertube.moe-vid.json") + body: File.read!("test/fixtures/tesla_mock/peertube.moe-vid.json"), + headers: activitypub_object_headers() }} end @@ -270,7 +288,8 @@ def get("https://framatube.org/accounts/framasoft", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/https___framatube.org_accounts_framasoft.json") + body: File.read!("test/fixtures/tesla_mock/https___framatube.org_accounts_framasoft.json"), + headers: activitypub_object_headers() }} end @@ -278,7 +297,8 @@ def get("https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206 {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/framatube.org-video.json") + body: File.read!("test/fixtures/tesla_mock/framatube.org-video.json"), + headers: activitypub_object_headers() }} end @@ -286,7 +306,8 @@ def get("https://peertube.social/accounts/craigmaloney", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/craigmaloney.json") + body: File.read!("test/fixtures/tesla_mock/craigmaloney.json"), + headers: activitypub_object_headers() }} end @@ -294,7 +315,8 @@ def get("https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34 {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/peertube-social.json") + body: File.read!("test/fixtures/tesla_mock/peertube-social.json"), + headers: activitypub_object_headers() }} end @@ -304,7 +326,8 @@ def get("https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39", _, {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/mobilizon.org-event.json") + body: File.read!("test/fixtures/tesla_mock/mobilizon.org-event.json"), + headers: activitypub_object_headers() }} end @@ -312,7 +335,8 @@ def get("https://mobilizon.org/@tcit", _, _, [{"accept", "application/activity+j {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/mobilizon.org-user.json") + body: File.read!("test/fixtures/tesla_mock/mobilizon.org-user.json"), + headers: activitypub_object_headers() }} end @@ -320,7 +344,8 @@ def get("https://baptiste.gelez.xyz/@/BaptisteGelez", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/baptiste.gelex.xyz-user.json") + body: File.read!("test/fixtures/tesla_mock/baptiste.gelex.xyz-user.json"), + headers: activitypub_object_headers() }} end @@ -328,7 +353,8 @@ def get("https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june- {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json") + body: File.read!("test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json"), + headers: activitypub_object_headers() }} end @@ -336,7 +362,8 @@ def get("https://wedistribute.org/wp-json/pterotype/v1/object/85810", _, _, _) d {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/wedistribute-article.json") + body: File.read!("test/fixtures/tesla_mock/wedistribute-article.json"), + headers: activitypub_object_headers() }} end @@ -344,7 +371,8 @@ def get("https://wedistribute.org/wp-json/pterotype/v1/actor/-blog", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/wedistribute-user.json") + body: File.read!("test/fixtures/tesla_mock/wedistribute-user.json"), + headers: activitypub_object_headers() }} end @@ -352,7 +380,8 @@ def get("http://mastodon.example.org/users/admin", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/admin@mastdon.example.org.json") + body: File.read!("test/fixtures/tesla_mock/admin@mastdon.example.org.json"), + headers: activitypub_object_headers() }} end @@ -362,7 +391,8 @@ def get("http://mastodon.example.org/users/relay", _, _, [ {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/relay@mastdon.example.org.json") + body: File.read!("test/fixtures/tesla_mock/relay@mastdon.example.org.json"), + headers: activitypub_object_headers() }} end @@ -482,7 +512,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/pekorino@pawoo.net_host_meta.json") + body: File.read!("test/fixtures/tesla_mock/pekorino@pawoo.net_host_meta.json"), + headers: activitypub_object_headers() }} end @@ -543,7 +574,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/mastodon-note-object.json") + body: File.read!("test/fixtures/mastodon-note-object.json"), + headers: activitypub_object_headers() }} end @@ -567,7 +599,8 @@ def get("https://mstdn.io/users/mayuutann", _, _, [{"accept", "application/activ {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/mayumayu.json") + body: File.read!("test/fixtures/tesla_mock/mayumayu.json"), + headers: activitypub_object_headers() }} end @@ -580,7 +613,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/mayumayupost.json") + body: File.read!("test/fixtures/tesla_mock/mayumayupost.json"), + headers: activitypub_object_headers() }} end @@ -795,7 +829,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/winterdienst_webfinger.json") + body: File.read!("test/fixtures/tesla_mock/winterdienst_webfinger.json"), + headers: activitypub_object_headers() }} end @@ -867,12 +902,21 @@ def get("https://social.heldscal.la/.well-known/host-meta", _, _, _) do end def get("https://mastodon.social/users/lambadalambda", _, _, _) do - {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/lambadalambda.json")}} + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/lambadalambda.json"), + headers: activitypub_object_headers() + }} end def get("https://apfed.club/channel/indio", _, _, _) do {:ok, - %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/osada-user-indio.json")}} + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/osada-user-indio.json"), + headers: activitypub_object_headers() + }} end def get("https://social.heldscal.la/user/23211", _, _, [{"accept", "application/activity+json"}]) do @@ -895,7 +939,8 @@ def get("http://localhost:4001/users/masto_closed/followers", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/users_mock/masto_closed_followers.json") + body: File.read!("test/fixtures/users_mock/masto_closed_followers.json"), + headers: activitypub_object_headers() }} end @@ -903,7 +948,8 @@ def get("http://localhost:4001/users/masto_closed/followers?page=1", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/users_mock/masto_closed_followers_page.json") + body: File.read!("test/fixtures/users_mock/masto_closed_followers_page.json"), + headers: activitypub_object_headers() }} end @@ -911,7 +957,8 @@ def get("http://localhost:4001/users/masto_closed/following", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/users_mock/masto_closed_following.json") + body: File.read!("test/fixtures/users_mock/masto_closed_following.json"), + headers: activitypub_object_headers() }} end @@ -919,7 +966,8 @@ def get("http://localhost:4001/users/masto_closed/following?page=1", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/users_mock/masto_closed_following_page.json") + body: File.read!("test/fixtures/users_mock/masto_closed_following_page.json"), + headers: activitypub_object_headers() }} end @@ -927,7 +975,8 @@ def get("http://localhost:8080/followers/fuser3", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/users_mock/friendica_followers.json") + body: File.read!("test/fixtures/users_mock/friendica_followers.json"), + headers: activitypub_object_headers() }} end @@ -935,7 +984,8 @@ def get("http://localhost:8080/following/fuser3", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/users_mock/friendica_following.json") + body: File.read!("test/fixtures/users_mock/friendica_following.json"), + headers: activitypub_object_headers() }} end @@ -943,7 +993,8 @@ def get("http://localhost:4001/users/fuser2/followers", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/users_mock/pleroma_followers.json") + body: File.read!("test/fixtures/users_mock/pleroma_followers.json"), + headers: activitypub_object_headers() }} end @@ -951,7 +1002,8 @@ def get("http://localhost:4001/users/fuser2/following", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/users_mock/pleroma_following.json") + body: File.read!("test/fixtures/users_mock/pleroma_following.json"), + headers: activitypub_object_headers() }} end @@ -1049,7 +1101,8 @@ def get("https://info.pleroma.site/activity.json", _, _, [ {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity.json") + body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity.json"), + headers: activitypub_object_headers() }} end @@ -1063,7 +1116,8 @@ def get("https://info.pleroma.site/activity2.json", _, _, [ {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity2.json") + body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity2.json"), + headers: activitypub_object_headers() }} end @@ -1077,7 +1131,8 @@ def get("https://info.pleroma.site/activity3.json", _, _, [ {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity3.json") + body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity3.json"), + headers: activitypub_object_headers() }} end @@ -1110,7 +1165,12 @@ def get("https://www.patreon.com/posts/mastodon-2-9-and-28121681", _, _, _) do end def get("http://mastodon.example.org/@admin/99541947525187367", _, _, _) do - {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/mastodon-post-activity.json")}} + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/mastodon-post-activity.json"), + headers: activitypub_object_headers() + }} end def get("https://info.pleroma.site/activity4.json", _, _, _) do @@ -1137,7 +1197,8 @@ def get("https://skippers-bin.com/notes/7x9tmrp97i", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/misskey_poll_no_end_date.json") + body: File.read!("test/fixtures/tesla_mock/misskey_poll_no_end_date.json"), + headers: activitypub_object_headers() }} end @@ -1146,11 +1207,21 @@ def get("https://example.org/emoji/firedfox.png", _, _, _) do end def get("https://skippers-bin.com/users/7v1w1r8ce6", _, _, _) do - {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/sjw.json")}} + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/sjw.json"), + headers: activitypub_object_headers() + }} end def get("https://patch.cx/users/rin", _, _, _) do - {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/rin.json")}} + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/rin.json"), + headers: activitypub_object_headers() + }} end def get( @@ -1160,12 +1231,20 @@ def get( _ ) do {:ok, - %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/funkwhale_audio.json")}} + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/funkwhale_audio.json"), + headers: activitypub_object_headers() + }} end def get("https://channels.tests.funkwhale.audio/federation/actors/compositions", _, _, _) do {:ok, - %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/funkwhale_channel.json")}} + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/funkwhale_channel.json"), + headers: activitypub_object_headers() + }} end def get("http://example.com/rel_me/error", _, _, _) do @@ -1173,7 +1252,12 @@ def get("http://example.com/rel_me/error", _, _, _) do end def get("https://relay.mastodon.host/actor", _, _, _) do - {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/relay/relay.json")}} + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/relay/relay.json"), + headers: activitypub_object_headers() + }} end def get("http://localhost:4001/", _, "", [{"accept", "text/html"}]) do diff --git a/test/support/mocks.ex b/test/support/mocks.ex new file mode 100644 index 000000000..a600a6458 --- /dev/null +++ b/test/support/mocks.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +Mox.defmock(Pleroma.CachexMock, for: Pleroma.Caching) + +Mox.defmock(Pleroma.Web.ActivityPub.ObjectValidatorMock, + for: Pleroma.Web.ActivityPub.ObjectValidator.Validating +) + +Mox.defmock(Pleroma.Web.ActivityPub.MRFMock, + for: Pleroma.Web.ActivityPub.MRF.PipelineFiltering +) + +Mox.defmock(Pleroma.Web.ActivityPub.ActivityPubMock, + for: Pleroma.Web.ActivityPub.ActivityPub.Persisting +) + +Mox.defmock(Pleroma.Web.ActivityPub.SideEffectsMock, + for: Pleroma.Web.ActivityPub.SideEffects.Handling +) + +Mox.defmock(Pleroma.Web.FederatorMock, for: Pleroma.Web.Federator.Publishing) + +Mox.defmock(Pleroma.ConfigMock, for: Pleroma.Config.Getting) diff --git a/test/support/null_cache.ex b/test/support/null_cache.ex new file mode 100644 index 000000000..c63df6a39 --- /dev/null +++ b/test/support/null_cache.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright ยฉ 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.NullCache do + @moduledoc """ + A module simulating a permanently empty cache. + """ + @behaviour Pleroma.Caching + + @impl true + def get!(_, _), do: nil + + @impl true + def put(_, _, _, _ \\ nil), do: {:ok, true} + + @impl true + def stream!(_, _), do: [] + + @impl true + def get(_, _), do: {:ok, nil} + + @impl true + def fetch!(_, key, func) do + case func.(key) do + {_, res} -> res + res -> res + end + end + + @impl true + def get_and_update(_, _, func) do + func.(nil) + end + + @impl true + def expire_at(_, _, _), do: {:ok, true} + + @impl true + def exists?(_, _), do: {:ok, false} + + @impl true + def execute!(_, func) do + func.(:nothing) + end + + @impl true + def del(_, _), do: {:ok, true} +end diff --git a/test/support/oban_helpers.ex b/test/support/oban_helpers.ex index 9f90a821c..2468f66dc 100644 --- a/test/support/oban_helpers.ex +++ b/test/support/oban_helpers.ex @@ -7,6 +7,8 @@ defmodule Pleroma.Tests.ObanHelpers do Oban test helpers. """ + require Ecto.Query + alias Pleroma.Repo def wipe_all do @@ -15,6 +17,7 @@ def wipe_all do def perform_all do Oban.Job + |> Ecto.Query.where(state: "available") |> Repo.all() |> perform() end