Merge branch 'bugfix/1395-email-activation' into 'develop'
authorrinpatch <rinpatch@sdf.org>
Fri, 15 Nov 2019 14:11:48 +0000 (14:11 +0000)
committerrinpatch <rinpatch@sdf.org>
Fri, 15 Nov 2019 14:11:48 +0000 (14:11 +0000)
Bugfix/1395 email activation

Closes #1395

See merge request pleroma/pleroma!1965

68 files changed:
.gitlab-ci.yml
CHANGELOG.md
config/config.exs
docs/API/admin_api.md
docs/API/pleroma_api.md
docs/configuration/cheatsheet.md
docs/installation/openbsd_en.md
docs/installation/otp_en.md
lib/mix/tasks/pleroma/config.ex
lib/pleroma/activity.ex
lib/pleroma/application.ex
lib/pleroma/constants.ex
lib/pleroma/docs/json.ex
lib/pleroma/emoji-data.txt [new file with mode: 0644]
lib/pleroma/emoji.ex
lib/pleroma/object/containment.ex
lib/pleroma/plugs/rate_limiter.ex [deleted file]
lib/pleroma/plugs/rate_limiter/limiter_supervisor.ex [new file with mode: 0644]
lib/pleroma/plugs/rate_limiter/rate_limiter.ex [new file with mode: 0644]
lib/pleroma/plugs/rate_limiter/supervisor.ex [new file with mode: 0644]
lib/pleroma/plugs/static_fe_plug.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/mrf/object_age_policy.ex [new file with mode: 0644]
lib/pleroma/web/activity_pub/transmogrifier.ex
lib/pleroma/web/activity_pub/utils.ex
lib/pleroma/web/admin_api/admin_api_controller.ex
lib/pleroma/web/admin_api/views/report_view.ex
lib/pleroma/web/common_api/common_api.ex
lib/pleroma/web/mastodon_api/controllers/account_controller.ex
lib/pleroma/web/mastodon_api/controllers/auth_controller.ex
lib/pleroma/web/mastodon_api/controllers/search_controller.ex
lib/pleroma/web/mastodon_api/controllers/status_controller.ex
lib/pleroma/web/mastodon_api/views/conversation_view.ex
lib/pleroma/web/mongooseim/mongoose_im_controller.ex
lib/pleroma/web/nodeinfo/nodeinfo_controller.ex
lib/pleroma/web/oauth/oauth_controller.ex
lib/pleroma/web/ostatus/ostatus_controller.ex
lib/pleroma/web/pleroma_api/controllers/account_controller.ex
lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex
lib/pleroma/web/router.ex
lib/pleroma/web/static_fe/static_fe_controller.ex [new file with mode: 0644]
lib/pleroma/web/static_fe/static_fe_view.ex [new file with mode: 0644]
lib/pleroma/web/templates/layout/static_fe.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/static_fe/static_fe/error.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex [new file with mode: 0644]
mix.exs
mix.lock
priv/static/schemas/litepub-0.1.jsonld
priv/static/static/static-fe.css [new file with mode: 0644]
test/emoji_test.exs
test/fixtures/emoji-reaction.json [new file with mode: 0644]
test/fixtures/misskey-like.json [new file with mode: 0644]
test/object/containment_test.exs
test/plugs/rate_limiter_test.exs
test/web/activity_pub/activity_pub_test.exs
test/web/activity_pub/mrf/object_age_policy_test.exs [new file with mode: 0644]
test/web/activity_pub/transmogrifier_test.exs
test/web/activity_pub/utils_test.exs
test/web/admin_api/admin_api_controller_test.exs
test/web/common_api/common_api_test.exs
test/web/node_info_test.exs
test/web/oauth/oauth_controller_test.exs
test/web/pleroma_api/controllers/pleroma_api_controller_test.exs
test/web/static_fe/static_fe_controller_test.exs [new file with mode: 0644]

index 0f8a0659b7ae9c10aca62eb52d44fb990fd646ce..ab62c88275958f118e6783567864eb5daf1e8c78 100644 (file)
@@ -34,7 +34,7 @@ benchmark:
   variables:
     MIX_ENV: benchmark
   services:
-  - name: lainsoykaf/postgres-with-rum
+  - name: postgres:9.6
     alias: postgres
     command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
   script:
@@ -46,19 +46,19 @@ benchmark:
 unit-testing:
   stage: test
   services:
-  - name: lainsoykaf/postgres-with-rum
+  - name: postgres:9.6
     alias: postgres
     command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
   script:
     - mix deps.get
     - mix ecto.create
     - mix ecto.migrate
-    - mix coveralls --trace --preload-modules
+    - mix coveralls --preload-modules
 
 unit-testing-rum:
   stage: test
   services:
-  - name: lainsoykaf/postgres-with-rum
+  - name: minibikini/postgres-with-rum:12
     alias: postgres
     command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
   variables:
@@ -68,7 +68,7 @@ unit-testing-rum:
     - mix ecto.create
     - mix ecto.migrate
     - "mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/"
-    - mix test --trace --preload-modules
+    - mix test --preload-modules
 
 lint:
   stage: test
@@ -113,6 +113,7 @@ review_app:
     - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
     - ssh-keyscan -H "pleroma.online" >> ~/.ssh/known_hosts
     - (ssh -t dokku@pleroma.online -- apps:create "$CI_ENVIRONMENT_SLUG") || true
+    - (ssh -t dokku@pleroma.online -- git:set "$CI_ENVIRONMENT_SLUG" keep-git-dir true) || true
     - ssh -t dokku@pleroma.online -- config:set "$CI_ENVIRONMENT_SLUG" APP_NAME="$CI_ENVIRONMENT_SLUG" APP_HOST="$CI_ENVIRONMENT_SLUG.pleroma.online" MIX_ENV=dokku
     - (ssh -t dokku@pleroma.online -- postgres:create $(echo $CI_ENVIRONMENT_SLUG | sed -e 's/-/_/g')_db) || true
     - (ssh -t dokku@pleroma.online -- postgres:link $(echo $CI_ENVIRONMENT_SLUG | sed -e 's/-/_/g')_db "$CI_ENVIRONMENT_SLUG") || true
@@ -138,7 +139,7 @@ stop_review_app:
     - ssh -t dokku@pleroma.online -- --force postgres:destroy $(echo $CI_ENVIRONMENT_SLUG | sed -e 's/-/_/g')_db
 
 amd64:
-  stage: release 
+  stage: release
   # TODO: Replace with upstream image when 1.9.0 comes out
   image: rinpatch/elixir:1.9.0-rc.0
   only: &release-only
index 4ec084dbd16975d5020d88957777e059d6077e89..a675fc4260dd7c9efc25962bd8ca67f19e77dbd3 100644 (file)
@@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 - **Breaking** Admin API: `PATCH /api/pleroma/admin/users/:nickname/force_password_reset` is now `PATCH /api/pleroma/admin/users/force_password_reset` (accepts `nicknames` array in the request body)
 - **Breaking:** Admin API: Return link alongside with token on password reset
+- **Breaking:** Admin API: `PUT /api/pleroma/admin/reports/:id` is now `PATCH /api/pleroma/admin/reports`, see admin_api.md for details
 - **Breaking:** `/api/pleroma/admin/users/invite_token` now uses `POST`, changed accepted params and returns full invite in json instead of only token string.
 - Admin API: Return `total` when querying for reports
 - Mastodon API: Return `pleroma.direct_conversation_id` when creating a direct message (`POST /api/v1/statuses`)
@@ -38,12 +39,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 ### Added
 - Refreshing poll results for remote polls
 - Authentication: Added rate limit for password-authorized actions / login existence checks
+- Static Frontend: Add the ability to render user profiles and notices server-side without requiring JS app.
 - Mix task to re-count statuses for all users (`mix pleroma.count_statuses`)
 - Support for `X-Forwarded-For` and similar HTTP headers which used by reverse proxies to pass a real user IP address to the backend. Must not be enabled unless your instance is behind at least one reverse proxy (such as Nginx, Apache HTTPD or Varnish Cache).
+- MRF: New module which handles incoming posts based on their age. By default, all incoming posts that are older than 2 days will be unlisted and not shown to their followers.
 <details>
   <summary>API Changes</summary>
 
 - Job queue stats to the healthcheck page
+- Admin API: Add ability to fetch reports, grouped by status `GET /api/pleroma/admin/grouped_reports`
 - Admin API: Add ability to require password reset
 - Mastodon API: Account entities now include `follow_requests_count` (planned Mastodon 3.x addition)
 - Pleroma API: `GET /api/v1/pleroma/accounts/:id/scrobbles` to get a list of recently scrobbled items
@@ -52,18 +56,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Mastodon API: Add `pleroma.unread_conversation_count` to the Account entity
 - OAuth: support for hierarchical permissions / [Mastodon 2.4.3 OAuth permissions](https://docs.joinmastodon.org/api/permissions/)
 - Metadata Link: Atom syndication Feed
+- Mix task to re-count statuses for all users (`mix pleroma.count_statuses`)
 - Mastodon API: Add `exclude_visibilities` parameter to the timeline and notification endpoints
 - Admin API: `/users/:nickname/toggle_activation` endpoint is now deprecated in favor of: `/users/activate`, `/users/deactivate`, both accept `nicknames` array
-- Admin API: `POST/DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group` are deprecated in favor of: `POST/DELETE /api/pleroma/admin/users/permission_group/:permission_group` (both accept `nicknames` array), `DELETE /api/pleroma/admin/users` (`nickname` query param or `nickname` sent in JSON body) is deprecated in favor of: `DELETE /api/pleroma/admin/users` (`nicknames` query array param or `nicknames` sent in JSON body).
+- Admin API: Multiple endpoints now require `nicknames` array, instead of singe `nickname`:
+  - `POST/DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group` are deprecated in favor of: `POST/DELETE /api/pleroma/admin/users/permission_group/:permission_group`
+  - `DELETE /api/pleroma/admin/users` (`nickname` query param or `nickname` sent in JSON body) is deprecated in favor of: `DELETE /api/pleroma/admin/users` (`nicknames` query array param or `nicknames` sent in JSON body)
 - Admin API: Add `GET /api/pleroma/admin/relay` endpoint - lists all followed relays
 - Pleroma API: `POST /api/v1/pleroma/conversations/read` to mark all conversations as read
 - Mastodon API: Add `/api/v1/markers` for managing timeline read markers
 - Mastodon API: Add the `recipients` parameter to `GET /api/v1/conversations`
 - Configuration: `feed` option for user atom feed.
+- Pleroma API: Add Emoji reactions
 </details>
 
 ### Fixed
 - Report emails now include functional links to profiles of remote user accounts
+- Not being able to log in to some third-party apps when logged in to MastoFE
 <details>
   <summary>API Changes</summary>
 
index 54de8fa9fa3faf4530678b11a16abf7259dbccb4..bf2b3f6e28379208e57eae5e1bdc367402feec0e 100644 (file)
@@ -274,7 +274,7 @@ config :pleroma, :instance,
   account_field_name_length: 512,
   account_field_value_length: 2048,
   external_user_synchronization: true,
-  extended_nickname_format: false
+  extended_nickname_format: true
 
 config :pleroma, :feed,
   post_title: %{
@@ -381,6 +381,10 @@ config :pleroma, :mrf_vocabulary,
   accept: [],
   reject: []
 
+config :pleroma, :mrf_object_age,
+  threshold: 172_800,
+  actions: [:delist, :strip_followers]
+
 config :pleroma, :rich_media,
   enabled: true,
   ignore_hosts: [],
@@ -605,6 +609,8 @@ config :pleroma, Pleroma.ActivityExpiration, enabled: true
 
 config :pleroma, Pleroma.Plugs.RemoteIp, enabled: false
 
+config :pleroma, :static_fe, enabled: false
+
 config :pleroma, :web_cache_ttl,
   activity_pub: nil,
   activity_pub_question: 30_000
index c042b08aceb112d7e4f60176725a7665833d94b1..9d914c9a6360e02b0b72235b1232d210b7d656a9 100644 (file)
@@ -2,11 +2,10 @@
 
 Authentication is required and the user must be an admin.
 
-## `/api/pleroma/admin/users`
+## `GET /api/pleroma/admin/users`
 
 ### List users
 
-- Method `GET`
 - Query Params:
   - *optional* `query`: **string** search term (e.g. nickname, domain, nickname@domain)
   - *optional* `filters`: **string** comma-separated string of filters:
@@ -51,7 +50,6 @@ Authentication is required and the user must be an admin.
 
 ### Remove a user
 
-- Method `DELETE`
 - Params:
   - `nickname`
 - Response: User’s nickname
@@ -60,7 +58,6 @@ Authentication is required and the user must be an admin.
 
 ### Remove a user
 
-- Method `DELETE`
 - Params:
   - `nicknames`
 - Response: Array of user nicknames
@@ -78,31 +75,30 @@ Authentication is required and the user must be an admin.
   ]
 - Response: User’s nickname
 
-## `/api/pleroma/admin/users/follow`
+## `POST /api/pleroma/admin/users/follow`
+
 ### Make a user follow another user
 
-- Methods: `POST`
 - Params:
- - `follower`: The nickname of the follower
- - `followed`: The nickname of the followed
 - `follower`: The nickname of the follower
 - `followed`: The nickname of the followed
 - Response:
- - "ok"
+  - "ok"
+
+## `POST /api/pleroma/admin/users/unfollow`
 
-## `/api/pleroma/admin/users/unfollow`
 ### Make a user unfollow another user
 
-- Methods: `POST`
 - Params:
- - `follower`: The nickname of the follower
- - `followed`: The nickname of the followed
 - `follower`: The nickname of the follower
 - `followed`: The nickname of the followed
 - Response:
- - "ok"
 - "ok"
 
-## `/api/pleroma/admin/users/:nickname/toggle_activation`
+## `PATCH /api/pleroma/admin/users/:nickname/toggle_activation`
 
 ### Toggle user activation
 
-- Method: `PATCH`
 - Params:
   - `nickname`
 - Response: User’s object
@@ -115,27 +111,26 @@ Authentication is required and the user must be an admin.
 }
 ```
 
-## `/api/pleroma/admin/users/tag`
+## `PUT /api/pleroma/admin/users/tag`
 
 ### Tag a list of users
 
-- Method: `PUT`
 - Params:
   - `nicknames` (array)
   - `tags` (array)
 
+## `DELETE /api/pleroma/admin/users/tag`
+
 ### Untag a list of users
 
-- Method: `DELETE`
 - Params:
   - `nicknames` (array)
   - `tags` (array)
 
-## `/api/pleroma/admin/users/:nickname/permission_group`
+## `GET /api/pleroma/admin/users/:nickname/permission_group`
 
 ### Get user user permission groups membership
 
-- Method: `GET`
 - Params: none
 - Response:
 
@@ -146,13 +141,12 @@ Authentication is required and the user must be an admin.
 }
 ```
 
-## `/api/pleroma/admin/users/:nickname/permission_group/:permission_group`
+## `GET /api/pleroma/admin/users/:nickname/permission_group/:permission_group`
 
 Note: Available `:permission_group` is currently moderator and admin. 404 is returned when the permission group doesn’t exist.
 
 ### Get user user permission groups membership per permission group
 
-- Method: `GET`
 - Params: none
 - Response:
 
@@ -184,6 +178,8 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 
 ## DEPRECATED `DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group`
 
+## `DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group`
+
 ### Remove user from permission group
 
 - Params: none
@@ -247,22 +243,20 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
   - `nickname`
   - `status` BOOLEAN field, false value means deactivation.
 
-## `/api/pleroma/admin/users/:nickname_or_id`
+## `GET /api/pleroma/admin/users/:nickname_or_id`
 
 ### Retrive the details of a user
 
-- Method: `GET`
 - Params:
   - `nickname` or `id`
 - Response:
   - On failure: `Not found`
   - On success: JSON of the user
 
-## `/api/pleroma/admin/users/:nickname_or_id/statuses`
+## `GET /api/pleroma/admin/users/:nickname_or_id/statuses`
 
 ### Retrive user's latest statuses
 
-- Method: `GET`
 - Params:
   - `nickname` or `id`
   - *optional* `page_size`: number of statuses to return (default is `20`)
@@ -271,19 +265,19 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
   - On failure: `Not found`
   - On success: JSON array of user's latest statuses
 
-## `/api/pleroma/admin/relay`
+## `POST /api/pleroma/admin/relay`
 
 ### Follow a Relay
 
-- Methods: `POST`
 - Params:
   - `relay_url`
 - Response:
   - On success: URL of the followed relay
 
+## `DELETE /api/pleroma/admin/relay`
+
 ### Unfollow a Relay
 
-- Methods: `DELETE`
 - Params:
   - `relay_url`
 - Response:
@@ -297,11 +291,10 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 - Response:
   - On success: JSON array of relays
 
-## `/api/pleroma/admin/users/invite_token`
+## `POST /api/pleroma/admin/users/invite_token`
 
 ### Create an account registration invite token
 
-- Methods: `POST`
 - Params:
   - *optional* `max_use` (integer)
   - *optional* `expires_at` (date string e.g. "2019-04-07")
@@ -319,11 +312,10 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 }
 ```
 
-## `/api/pleroma/admin/users/invites`
+## `GET /api/pleroma/admin/users/invites`
 
 ### Get a list of generated invites
 
-- Methods: `GET`
 - Params: none
 - Response:
 
@@ -345,11 +337,10 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 }
 ```
 
-## `/api/pleroma/admin/users/revoke_invite`
+## `POST /api/pleroma/admin/users/revoke_invite`
 
 ### Revoke invite by token
 
-- Methods: `POST`
 - Params:
   - `token`
 - Response:
@@ -367,21 +358,18 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 }
 ```
 
-
-## `/api/pleroma/admin/users/email_invite`
+## `POST /api/pleroma/admin/users/email_invite`
 
 ### Sends registration invite via email
 
-- Methods: `POST`
 - Params:
   - `email`
   - `name`, optional
 
-## `/api/pleroma/admin/users/:nickname/password_reset`
+## `GET /api/pleroma/admin/users/:nickname/password_reset`
 
 ### Get a password reset token for a given nickname
 
-- Methods: `GET`
 - Params: none
 - Response:
 
@@ -392,18 +380,18 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 }
 ```
 
-## `/api/pleroma/admin/users/force_password_reset`
+## `PATCH /api/pleroma/admin/users/force_password_reset`
 
 ### Force passord reset for a user with a given nickname
 
-- Methods: `PATCH`
 - Params:
   - `nicknames`
 - Response: none (code `204`)
 
-## `/api/pleroma/admin/reports`
+## `GET /api/pleroma/admin/reports`
+
 ### Get a list of reports
-- Method `GET`
+
 - Params:
   - *optional* `state`: **string** the state of reports. Valid values are `open`, `closed` and `resolved`
   - *optional* `limit`: **integer** the number of records to retrieve
@@ -418,7 +406,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 
 ```json
 {
-  "total" : 1,
+  "totalReports" : 1,
   "reports": [
     {
       "account": {
@@ -560,9 +548,34 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 }
 ```
 
-## `/api/pleroma/admin/reports/:id`
+## `GET /api/pleroma/admin/grouped_reports`
+
+### Get a list of reports, grouped by status
+
+- Params: none
+- On success: JSON, returns a list of reports, where:
+  - `date`: date of the latest report
+  - `account`: the user who has been reported (see `/api/pleroma/admin/reports` for reference)
+  - `status`: reported status (see `/api/pleroma/admin/reports` for reference)
+  - `actors`: users who had reported this status (see `/api/pleroma/admin/reports` for reference)
+  - `reports`: reports (see `/api/pleroma/admin/reports` for reference)
+
+```json
+  "reports": [
+    {
+      "date": "2019-10-07T12:31:39.615149Z",
+      "account": { ... },
+      "status": { ... },
+      "actors": [{ ... }, { ... }],
+      "reports": [{ ... }]
+    }
+  ]
+```
+
+## `GET /api/pleroma/admin/reports/:id`
+
 ### Get an individual report
-- Method `GET`
+
 - Params:
   - `id`
 - Response:
@@ -571,22 +584,41 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
     - 404 Not Found `"Not found"`
   - On success: JSON, Report object (see above)
 
-## `/api/pleroma/admin/reports/:id`
-### Change the state of the report
-- Method `PUT`
+## `PATCH /api/pleroma/admin/reports`
+
+### Change the state of one or multiple reports
+
 - Params:
-  - `id`
-  - `state`: required, the new state. Valid values are `open`, `closed` and `resolved`
+
+```json
+  `reports`: [
+    {
+      `id`, // required, report id
+      `state` // required, the new state. Valid values are `open`, `closed` and `resolved`
+    },
+    ...
+  ]
+```
+
 - Response:
   - On failure:
-    - 400 Bad Request `"Unsupported state"`
-    - 403 Forbidden `{"error": "error_msg"}`
-    - 404 Not Found `"Not found"`
-  - On success: JSON, Report object (see above)
+    - 400 Bad Request, JSON:
+
+    ```json
+      [
+        {
+          `id`, // report id
+          `error` // error message
+        }
+      ]
+    ```
+
+  - On success: `204`, empty response
+
+## `POST /api/pleroma/admin/reports/:id/respond`
 
-## `/api/pleroma/admin/reports/:id/respond`
 ### Respond to a report
-- Method `POST`
+
 - Params:
   - `id`
   - `status`: required, the message
@@ -656,9 +688,10 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
 }
 ```
 
-## `/api/pleroma/admin/statuses/:id`
+## `PUT /api/pleroma/admin/statuses/:id`
+
 ### Change the scope of an individual reported status
-- Method `PUT`
+
 - Params:
   - `id`
   - `sensitive`: optional, valid values are `true` or `false`
@@ -670,9 +703,10 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
     - 404 Not Found `"Not found"`
   - On success: JSON, Mastodon Status entity
 
-## `/api/pleroma/admin/statuses/:id`
+## `DELETE /api/pleroma/admin/statuses/:id`
+
 ### Delete an individual reported status
-- Method `DELETE`
+
 - Params:
   - `id`
 - Response:
@@ -681,11 +715,12 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
     - 404 Not Found `"Not found"`
   - On success: 200 OK `{}`
 
+## `GET /api/pleroma/admin/config/migrate_to_db`
 
-## `/api/pleroma/admin/config/migrate_to_db`
 ### Run mix task pleroma.config migrate_to_db
+
 Copy settings on key `:pleroma` to DB.
-- Method `GET`
+
 - Params: none
 - Response:
 
@@ -693,10 +728,12 @@ Copy settings on key `:pleroma` to DB.
 {}
 ```
 
-## `/api/pleroma/admin/config/migrate_from_db`
+## `GET /api/pleroma/admin/config/migrate_from_db`
+
 ### Run mix task pleroma.config migrate_from_db
+
 Copy all settings from DB to `config/prod.exported_from_db.secret.exs` with deletion from DB.
-- Method `GET`
+
 - Params: none
 - Response:
 
@@ -704,10 +741,12 @@ Copy all settings from DB to `config/prod.exported_from_db.secret.exs` with dele
 {}
 ```
 
-## `/api/pleroma/admin/config`
+## `GET /api/pleroma/admin/config`
+
 ### List config settings
+
 List config settings only works with `:pleroma => :instance => :dynamic_configuration` setting to `true`.
-- Method `GET`
+
 - Params: none
 - Response:
 
@@ -723,8 +762,10 @@ List config settings only works with `:pleroma => :instance => :dynamic_configur
 }
 ```
 
-## `/api/pleroma/admin/config`
+## `POST /api/pleroma/admin/config`
+
 ### Update config settings
+
 Updating config settings only works with `:pleroma => :instance => :dynamic_configuration` setting to `true`.
 Module name can be passed as string, which starts with `Pleroma`, e.g. `"Pleroma.Upload"`.
 Atom keys and values can be passed with `:` in the beginning, e.g. `":upload"`.
@@ -747,7 +788,6 @@ Compile time settings (need instance reboot):
   - `Pleroma.Upload` -> `:proxy_remote`
   - `:instance` -> `:upload_limit`
 
-- Method `POST`
 - Params:
   - `configs` => [
     - `group` (string)
@@ -802,9 +842,10 @@ Compile time settings (need instance reboot):
 }
 ```
 
-## `/api/pleroma/admin/moderation_log`
+## `GET /api/pleroma/admin/moderation_log`
+
 ### Get moderation log
-- Method `GET`
+
 - Params:
   - *optional* `page`: **integer** page number
   - *optional* `page_size`: **integer** number of log entries per page (default is `50`)
@@ -831,8 +872,9 @@ Compile time settings (need instance reboot):
 ```
 
 ## `POST /api/pleroma/admin/reload_emoji`
+
 ### Reload the instance's custom emoji
-* Method `POST`
-* Authentication: required
-* Params: None
-* Response: JSON, "ok" and 200 status
+
+- Authentication: required
+- Params: None
+- Response: JSON, "ok" and 200 status
index 6c326dc9bc19efea8ef4e063faa6058d47353459..ad16d027e6e8d311741f6be092197d2b79f63db8 100644 (file)
@@ -479,3 +479,35 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa
   * `artist`: the artist of the media playing [optional]
   * `length`: the length of the media playing [optional]
 * Response: the newly created media metadata entity representing the Listen activity
+
+# Emoji Reactions
+
+Emoji reactions work a lot like favourites do. They make it possible to react to a post with a single emoji character.
+
+## `POST /api/v1/pleroma/statuses/:id/react_with_emoji`
+### React to a post with a unicode emoji
+* Method: `POST`
+* Authentication: required
+* Params: `emoji`: A single character unicode emoji
+* Response: JSON, the status.
+
+## `POST /api/v1/pleroma/statuses/:id/unreact_with_emoji`
+### Remove a reaction to a post with a unicode emoji
+* Method: `POST`
+* Authentication: required
+* Params: `emoji`: A single character unicode emoji
+* Response: JSON, the status.
+
+## `GET /api/v1/pleroma/statuses/:id/emoji_reactions_by`
+### Get an object of emoji to account mappings with accounts that reacted to the post
+* Method: `GET`
+* Authentication: optional
+* Params: None
+* Response: JSON, a map of emoji to account list mappings.
+* Example Response:
+```json
+{
+  "😀" => [{"id" => "xyz.."...}, {"id" => "zyx..."}],
+  "🗡" => [{"id" => "abc..."}] 
+}
+```
index 8f609fcfdbfe555afcdefaee21a474d884ae66e9..d798bd692ae1a0b38113e883dd9de3f759895081 100644 (file)
@@ -41,6 +41,7 @@ You shouldn't edit the base config directly to avoid breakages and merge conflic
     * `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed.
     * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)).
     * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)).
+    * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)).
 * `public`: Makes the client API in authentificated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network.
 * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send.
 * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``.
@@ -137,6 +138,13 @@ config :pleroma, :mrf_user_allowlist,
   "example.org": ["https://example.org/users/admin"]
 ```
 
+#### :mrf_object_age
+* `threshold`: Required age (in seconds) of a post before actions are taken.
+* `actions`: 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
+
 ### :activitypub
 * ``unfollow_blocked``: Whether blocks result in people getting unfollowed
 * ``outgoing_blocks``: Whether to federate blocks to other instances
@@ -180,6 +188,14 @@ config :pleroma, :frontend_configurations,
 
 These settings **need to be complete**, they will override the defaults.
 
+### :static_fe
+
+Render profiles and posts using server-generated HTML that is viewable without using JavaScript.
+
+Available options:
+
+* `enabled` - Enables the rendering of static HTML. Defaults to `false`.
+
 ### :assets
 
 This section configures assets to be used with various frontends. Currently the only option
@@ -523,7 +539,7 @@ config :pleroma, :workers,
 
 Configuration for [Quantum](https://github.com/quantum-elixir/quantum-core) jobs scheduler.
 
-See [Quantum readme](https://github.com/quantum-elixir/quantum-core#usage) for the list of supported options. 
+See [Quantum readme](https://github.com/quantum-elixir/quantum-core#usage) for the list of supported options.
 
 Example:
 
@@ -593,6 +609,10 @@ See the [Quack Github](https://github.com/azohra/quack) for more details
 ## Database options
 
 ### RUM indexing for full text search
+
+!!! warning
+    It is recommended to use PostgreSQL v11 or newer. We have seen some minor issues with lower PostgreSQL versions.
+
 * `rum_enabled`: If RUM indexes should be used. Defaults to `false`.
 
 RUM indexes are an alternative indexing scheme that is not included in PostgreSQL by default. While they may eventually be mainlined, for now they have to be installed as a PostgreSQL extension from https://github.com/postgrespro/rum.
@@ -793,4 +813,3 @@ config :auto_linker,
     rel: "ugc"
   ]
 ```
-
index 3585a326ba181935f67292f5c588d81f3d5aa6ad..45602bd757c4fd58f0c539e41ec978abe67c7a1f 100644 (file)
@@ -1,9 +1,13 @@
 # Installing on OpenBSD
+
 This guide describes the installation and configuration of pleroma (and the required software to run it) on a single OpenBSD 6.4 server.
+
 For any additional information regarding commands and configuration files mentioned here, check the man pages [online](https://man.openbsd.org/) or directly on your server with the man command.
 
 #### Required software
+
 The following packages need to be installed:
+
   * elixir
   * gmake
   * ImageMagick
@@ -11,8 +15,11 @@ The following packages need to be installed:
   * postgresql-server
   * postgresql-contrib
 
-To install them, run the following command (with doas or as root):  
-`pkg_add elixir gmake ImageMagick git postgresql-server postgresql-contrib`
+To install them, run the following command (with doas or as root):
+
+```
+pkg_add elixir gmake ImageMagick git postgresql-server postgresql-contrib
+```
 
 Pleroma requires a reverse proxy, OpenBSD has relayd in base (and is used in this guide) and packages/ports are available for nginx (www/nginx) and apache (www/apache-httpd). Independently of the reverse proxy, [acme-client(1)](https://man.openbsd.org/acme-client) can be used to get a certificate from Let's Encrypt.
 
@@ -31,8 +38,8 @@ Create the \_pleroma user, assign it the pleroma login class and create its home
 #### Clone pleroma's directory
 Enter a shell as the \_pleroma user. As root, run `su _pleroma -;cd`. Then clone the repository with `git clone -b stable https://git.pleroma.social/pleroma/pleroma.git`. Pleroma is now installed in /home/\_pleroma/pleroma/, it will be configured and started at the end of this guide.
 
-#### Postgresql
-Start a shell as the \_postgresql user (as root run `su _postgresql -` then run the `initdb` command to initialize postgresql:  
+#### PostgreSQL
+Start a shell as the \_postgresql user (as root run `su _postgresql -` then run the `initdb` command to initialize postgresql:
 If you wish to not use the default location for postgresql's data (/var/postgresql/data), add the following switch at the end of the command: `-D <path>` and modify the `datadir` variable in the /etc/rc.d/postgresql script.
 
 When this is done, enable postgresql so that it starts on boot and start it. As root, run:
@@ -44,6 +51,7 @@ To check that it started properly and didn't fail right after starting, you can
 
 #### httpd
 httpd will have three fuctions:
+
   * redirect requests trying to reach the instance over http to the https URL
   * serve a robots.txt file
   * get Let's Encrypt certificates, with acme-client
@@ -76,9 +84,9 @@ types {
        include "/usr/share/misc/mime.types"
 }
 ```
-Do not forget to change *\<IPv4/6 address\>* to your server's address(es). If httpd should only listen on one protocol family, comment one of the two first *listen* options.
+Do not forget to change *<IPv4/6 address\>* to your server's address(es). If httpd should only listen on one protocol family, comment one of the two first *listen* options.
 
-Create the /var/www/htdocs/local/ folder and write the content of your robots.txt in /var/www/htdocs/local/robots.txt.  
+Create the /var/www/htdocs/local/ folder and write the content of your robots.txt in /var/www/htdocs/local/robots.txt.
 Check the configuration with `httpd -n`, if it is OK enable and start httpd (as root):
 ```
 rcctl enable httpd
@@ -86,7 +94,7 @@ rcctl start httpd
 ```
 
 #### acme-client
-acme-client is used to get SSL/TLS certificates from Let's Encrypt. 
+acme-client is used to get SSL/TLS certificates from Let's Encrypt.
 Insert the following configuration in /etc/acme-client.conf:
 ```
 #
@@ -107,7 +115,7 @@ domain <domain name> {
        challengedir "/var/www/acme/"
 }
 ```
-Replace *\<domain name\>* by the domain name you'll use for your instance. As root, run `acme-client -n` to check the config, then `acme-client -ADv <domain name>` to create account and domain keys, and request a certificate for the first time.  
+Replace *<domain name\>* by the domain name you'll use for your instance. As root, run `acme-client -n` to check the config, then `acme-client -ADv <domain name>` to create account and domain keys, and request a certificate for the first time.
 Make acme-client run everyday by adding it in /etc/daily.local. As root, run the following command: `echo "acme-client <domain name>" >> /etc/daily.local`.
 
 Relayd will look for certificates and keys based on the address it listens on (see next part), the easiest way to make them available to relayd is to create a link, as root run:
@@ -118,7 +126,7 @@ ln -s /etc/ssl/private/<domain name>.key /etc/ssl/private/<IP address>.key
 This will have to be done for each IPv4 and IPv6 address relayd listens on.
 
 #### relayd
-relayd will be used as the reverse proxy sitting in front of pleroma. 
+relayd will be used as the reverse proxy sitting in front of pleroma.
 Insert the following configuration in /etc/relayd.conf:
 ```
 # $OpenBSD: relayd.conf,v 1.4 2018/03/23 09:55:06 claudio Exp $
@@ -169,7 +177,7 @@ relay wwwtls {
        forward to <httpd_server> port 80 check http "/robots.txt" code 200
 }
 ```
-Again, change *\<IPv4/6 address\>* to your server's address(es) and comment one of the two *listen* options if needed. Also change *wss://CHANGEME.tld* to *wss://\<your instance's domain name\>*.  
+Again, change *<IPv4/6 address\>* to your server's address(es) and comment one of the two *listen* options if needed. Also change *wss://CHANGEME.tld* to *wss://<your instance's domain name\>*.
 Check the configuration with `relayd -n`, if it is OK enable and start relayd (as root):
 ```
 rcctl enable relayd
@@ -177,7 +185,7 @@ rcctl start relayd
 ```
 
 #### pf
-Enabling and configuring pf is highly recommended.  
+Enabling and configuring pf is highly recommended.
 In /etc/pf.conf, insert the following configuration:
 ```
 # Macros
@@ -202,20 +210,22 @@ pass in quick on $if inet6 proto icmp6 to ($if) icmp6-type { echoreq unreach par
 pass in quick on $if proto tcp to ($if) port { http https } # relayd/httpd
 pass in quick on $if proto tcp from $authorized_ssh_clients to ($if) port ssh
 ```
-Replace *\<network interface\>* by your server's network interface name (which you can get with ifconfig). Consider replacing the content of the authorized\_ssh\_clients macro by, for exemple, your home IP address, to avoid SSH connection attempts from bots.
+Replace *<network interface\>* by your server's network interface name (which you can get with ifconfig). Consider replacing the content of the authorized\_ssh\_clients macro by, for exemple, your home IP address, to avoid SSH connection attempts from bots.
 
 Check pf's configuration by running `pfctl -nf /etc/pf.conf`, load it with `pfctl -f /etc/pf.conf` and enable pf at boot with `rcctl enable pf`.
 
 #### Configure and start pleroma
-Enter a shell as \_pleroma (as root `su _pleroma -`) and enter pleroma's installation directory (`cd ~/pleroma/`).  
+Enter a shell as \_pleroma (as root `su _pleroma -`) and enter pleroma's installation directory (`cd ~/pleroma/`).
+
 Then follow the main installation guide:
+
   * run `mix deps.get`
   * run `mix pleroma.instance gen` and enter your instance's information when asked
   * copy config/generated\_config.exs to config/prod.secret.exs. The default values should be sufficient but you should edit it and check that everything seems OK.
   * exit your current shell back to a root one and run `psql -U postgres -f /home/_pleroma/config/setup_db.psql` to setup the database.
   * return to a \_pleroma shell into pleroma's installation directory (`su _pleroma -;cd ~/pleroma`) and run `MIX_ENV=prod mix ecto.migrate`
 
-As \_pleroma in /home/\_pleroma/pleroma, you can now run `LC_ALL=en_US.UTF-8 MIX_ENV=prod mix phx.server` to start your instance.  
+As \_pleroma in /home/\_pleroma/pleroma, you can now run `LC_ALL=en_US.UTF-8 MIX_ENV=prod mix phx.server` to start your instance.
 In another SSH session/tmux window, check that it is working properly by running `ftp -MVo - http://127.0.0.1:4000/api/v1/instance`, you should get json output. Double-check that *uri*'s value is your instance's domain name.
 
 ##### Starting pleroma at boot
index c028f4229983ff82c3e7c9e677732e9a16972925..965e30e2ac87073339551a47e63b78cb62a3dfa3 100644 (file)
@@ -42,6 +42,10 @@ apk add curl unzip ncurses postgresql postgresql-contrib nginx certbot
 ## Setup
 ### Configuring PostgreSQL
 #### (Optional) Installing RUM indexes
+
+!!! warning
+    It is recommended to use PostgreSQL v11 or newer. We have seen some minor issues with lower PostgreSQL versions.
+
 RUM indexes are an alternative indexing scheme that is not included in PostgreSQL by default. You can read more about them on the [Configuration page](../configuration/cheatsheet.md#rum-indexing-for-full-text-search). They are completely optional and most of the time are not worth it, especially if you are running a single user instance (unless you absolutely need ordered search results).
 
 Debian/Ubuntu (available only on Buster/19.04):
@@ -74,7 +78,7 @@ rc-service postgresql restart
 # Create the Pleroma user
 adduser --system --shell  /bin/false --home /opt/pleroma pleroma
 
-# Set the flavour environment variable to the string you got in Detecting flavour section. 
+# Set the flavour environment variable to the string you got in Detecting flavour section.
 # For example if the flavour is `arm64-musl` the command will be
 export FLAVOUR="arm64-musl"
 
@@ -180,7 +184,7 @@ rc-service pleroma start
 rc-update add pleroma
 ```
 
-If everything worked, you should see Pleroma-FE when visiting your domain. If that didn't happen, try reviewing the installation steps, starting Pleroma in the foreground and seeing if there are any errrors. 
+If everything worked, you should see Pleroma-FE when visiting your domain. If that didn't happen, try reviewing the installation steps, starting Pleroma in the foreground and seeing if there are any errrors.
 
 Still doesn't work? Feel free to contact us on [#pleroma on freenode](https://webchat.freenode.net/?channels=%23pleroma) or via matrix at <https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org>, you can also [file an issue on our Gitlab](https://git.pleroma.social/pleroma/pleroma/issues/new)
 
index 11e4fde43eaa7932c817461a61a5b8231d6b87cb..0e21408b2b8b315cde8248fff16aeb9540c077b1 100644 (file)
@@ -45,7 +45,7 @@ defmodule Mix.Tasks.Pleroma.Config do
     if Pleroma.Config.get([:instance, :dynamic_configuration]) do
       config_path = "config/#{env}.exported_from_db.secret.exs"
 
-      {:ok, file} = File.open(config_path, [:write])
+      {:ok, file} = File.open(config_path, [:write, :utf8])
       IO.write(file, "use Mix.Config\r\n")
 
       Repo.all(Config)
index c1065611bf31d9eccc5e8eba005ac8cc210c55e4..7e283df32c01aeae7ac4eb981a670aef66e09da0 100644 (file)
@@ -41,6 +41,10 @@ defmodule Pleroma.Activity do
     field(:actor, :string)
     field(:recipients, {:array, :string}, default: [])
     field(:thread_muted?, :boolean, virtual: true)
+
+    # This is a fake relation,
+    # do not use outside of with_preloaded_user_actor/with_joined_user_actor
+    has_one(:user_actor, User, on_delete: :nothing, foreign_key: :id)
     # This is a fake relation, do not use outside of with_preloaded_bookmark/get_bookmark
     has_one(:bookmark, Bookmark)
     has_many(:notifications, Notification, on_delete: :delete_all)
@@ -86,6 +90,19 @@ defmodule Pleroma.Activity do
     |> preload([activity, object: object], object: object)
   end
 
+  def with_joined_user_actor(query, join_type \\ :inner) do
+    join(query, join_type, [activity], u in User,
+      on: u.ap_id == activity.actor,
+      as: :user_actor
+    )
+  end
+
+  def with_preloaded_user_actor(query, join_type \\ :inner) do
+    query
+    |> with_joined_user_actor(join_type)
+    |> preload([activity, user_actor: user_actor], user_actor: user_actor)
+  end
+
   def with_preloaded_bookmark(query, %User{} = user) do
     from([a] in query,
       left_join: b in Bookmark,
index d681eecc80f95920577e6c81263800c6e3d89d53..2b6a55f98fda23c120db6e170374a633fa92b1c3 100644 (file)
@@ -36,7 +36,8 @@ defmodule Pleroma.Application do
         Pleroma.Emoji,
         Pleroma.Captcha,
         Pleroma.Daemons.ScheduledActivityDaemon,
-        Pleroma.Daemons.ActivityExpirationDaemon
+        Pleroma.Daemons.ActivityExpirationDaemon,
+        Pleroma.Plugs.RateLimiter.Supervisor
       ] ++
         cachex_children() ++
         hackney_pool_children() ++
index 0bf20cdd0ecb8b384b93b194ed3d73d043e2fbb6..1a432e6816ee37edb14e50bc33bbecced6b52305 100644 (file)
@@ -9,6 +9,8 @@ defmodule Pleroma.Constants do
 
   const(object_internal_fields,
     do: [
+      "reactions",
+      "reaction_count",
       "likes",
       "like_count",
       "announcements",
index 18ba01d58aca56bb58b5edd38bdc738bb10d87c6..f2a56d845e594752db66cd24d67483be0f9f6221 100644 (file)
@@ -5,7 +5,7 @@ defmodule Pleroma.Docs.JSON do
   def process(descriptions) do
     config_path = "docs/generate_config.json"
 
-    with {:ok, file} <- File.open(config_path, [:write]),
+    with {:ok, file} <- File.open(config_path, [:write, :utf8]),
          json <- generate_json(descriptions),
          :ok <- IO.write(file, json),
          :ok <- File.close(file) do
diff --git a/lib/pleroma/emoji-data.txt b/lib/pleroma/emoji-data.txt
new file mode 100644 (file)
index 0000000..2fb5c3f
--- /dev/null
@@ -0,0 +1,769 @@
+# 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: 
+# <codepoint(s)> ; <property> # <comments> 
+# 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] (🀬..🀯)    <reserved-1F02C>..<reserved-1F02F>
+1F030..1F093  ; Extended_Pictographic#  5.1[100] (🀰..🂓)    DOMINO TILE HORIZONTAL BACK..DOMINO TILE VERTICAL-06-06
+1F094..1F09F  ; Extended_Pictographic#   NA [12] (🂔..🂟)    <reserved-1F094>..<reserved-1F09F>
+1F0A0..1F0AE  ; Extended_Pictographic#  6.0 [15] (🂠..🂮)    PLAYING CARD BACK..PLAYING CARD KING OF SPADES
+1F0AF..1F0B0  ; Extended_Pictographic#   NA  [2] (🂯..🂰)    <reserved-1F0AF>..<reserved-1F0B0>
+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] (🃀)       <reserved-1F0C0>
+1F0C1..1F0CF  ; Extended_Pictographic#  6.0 [15] (🃁..🃏)    PLAYING CARD ACE OF DIAMONDS..joker
+1F0D0         ; Extended_Pictographic#   NA  [1] (🃐)       <reserved-1F0D0>
+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] (🃶..🃿)    <reserved-1F0F6>..<reserved-1F0FF>
+1F10D..1F10F  ; Extended_Pictographic#   NA  [3] (🄍..🄏)    <reserved-1F10D>..<reserved-1F10F>
+1F12F         ; Extended_Pictographic# 11.0  [1] (🄯)       COPYLEFT SYMBOL
+1F16C         ; Extended_Pictographic# 12.0  [1] (🅬)       RAISED MR SIGN
+1F16D..1F16F  ; Extended_Pictographic#   NA  [3] (🅭..🅯)    <reserved-1F16D>..<reserved-1F16F>
+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] (🆭..🇥)    <reserved-1F1AD>..<reserved-1F1E5>
+1F201..1F202  ; Extended_Pictographic#  6.0  [2] (🈁..🈂️)    Japanese “here” button..Japanese “service charge” button
+1F203..1F20F  ; Extended_Pictographic#   NA [13] (🈃..🈏)    <reserved-1F203>..<reserved-1F20F>
+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] (🈼..🈿)    <reserved-1F23C>..<reserved-1F23F>
+1F249..1F24F  ; Extended_Pictographic#   NA  [7] (🉉..🉏)    <reserved-1F249>..<reserved-1F24F>
+1F250..1F251  ; Extended_Pictographic#  6.0  [2] (🉐..🉑)    Japanese “bargain” button..Japanese “acceptable” button
+1F252..1F25F  ; Extended_Pictographic#   NA [14] (🉒..🉟)    <reserved-1F252>..<reserved-1F25F>
+1F260..1F265  ; Extended_Pictographic# 10.0  [6] (🉠..🉥)    ROUNDED SYMBOL FOR FU..ROUNDED SYMBOL FOR CAI
+1F266..1F2FF  ; Extended_Pictographic#   NA[154] (🉦..🋿)    <reserved-1F266>..<reserved-1F2FF>
+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] (🛖..🛟)    <reserved-1F6D6>..<reserved-1F6DF>
+1F6E0..1F6EC  ; Extended_Pictographic#  7.0 [13] (🛠️..🛬)    hammer and wrench..airplane arrival
+1F6ED..1F6EF  ; Extended_Pictographic#   NA  [3] (🛭..🛯)    <reserved-1F6ED>..<reserved-1F6EF>
+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] (🛻..🛿)    <reserved-1F6FB>..<reserved-1F6FF>
+1F774..1F77F  ; Extended_Pictographic#   NA [12] (🝴..🝿)    <reserved-1F774>..<reserved-1F77F>
+1F7D5..1F7D8  ; Extended_Pictographic# 11.0  [4] (🟕..🟘)    CIRCLED TRIANGLE..NEGATIVE CIRCLED SQUARE
+1F7D9..1F7DF  ; Extended_Pictographic#   NA  [7] (🟙..🟟)    <reserved-1F7D9>..<reserved-1F7DF>
+1F7E0..1F7EB  ; Extended_Pictographic# 12.0 [12] (🟠..🟫)    orange circle..brown square
+1F7EC..1F7FF  ; Extended_Pictographic#   NA [20] (🟬..🟿)    <reserved-1F7EC>..<reserved-1F7FF>
+1F80C..1F80F  ; Extended_Pictographic#   NA  [4] (🠌..🠏)    <reserved-1F80C>..<reserved-1F80F>
+1F848..1F84F  ; Extended_Pictographic#   NA  [8] (🡈..🡏)    <reserved-1F848>..<reserved-1F84F>
+1F85A..1F85F  ; Extended_Pictographic#   NA  [6] (🡚..🡟)    <reserved-1F85A>..<reserved-1F85F>
+1F888..1F88F  ; Extended_Pictographic#   NA  [8] (🢈..🢏)    <reserved-1F888>..<reserved-1F88F>
+1F8AE..1F8FF  ; Extended_Pictographic#   NA [82] (🢮..🣿)    <reserved-1F8AE>..<reserved-1F8FF>
+1F90C         ; Extended_Pictographic#   NA  [1] (🤌)       <reserved-1F90C>
+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] (🥲)       <reserved-1F972>
+1F973..1F976  ; Extended_Pictographic# 11.0  [4] (🥳..🥶)    partying face..cold face
+1F977..1F979  ; Extended_Pictographic#   NA  [3] (🥷..🥹)    <reserved-1F977>..<reserved-1F979>
+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] (🦣..🦤)    <reserved-1F9A3>..<reserved-1F9A4>
+1F9A5..1F9AA  ; Extended_Pictographic# 12.0  [6] (🦥..🦪)    sloth..oyster
+1F9AB..1F9AD  ; Extended_Pictographic#   NA  [3] (🦫..🦭)    <reserved-1F9AB>..<reserved-1F9AD>
+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] (🧋..🧌)    <reserved-1F9CB>..<reserved-1F9CC>
+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] (🩔..🩟)    <reserved-1FA54>..<reserved-1FA5F>
+1FA60..1FA6D  ; Extended_Pictographic# 11.0 [14] (🩠..🩭)    XIANGQI RED GENERAL..XIANGQI BLACK SOLDIER
+1FA6E..1FA6F  ; Extended_Pictographic#   NA  [2] (🩮..🩯)    <reserved-1FA6E>..<reserved-1FA6F>
+1FA70..1FA73  ; Extended_Pictographic# 12.0  [4] (🩰..🩳)    ballet shoes..shorts
+1FA74..1FA77  ; Extended_Pictographic#   NA  [4] (🩴..🩷)    <reserved-1FA74>..<reserved-1FA77>
+1FA78..1FA7A  ; Extended_Pictographic# 12.0  [3] (🩸..🩺)    drop of blood..stethoscope
+1FA7B..1FA7F  ; Extended_Pictographic#   NA  [5] (🩻..🩿)    <reserved-1FA7B>..<reserved-1FA7F>
+1FA80..1FA82  ; Extended_Pictographic# 12.0  [3] (🪀..🪂)    yo-yo..parachute
+1FA83..1FA8F  ; Extended_Pictographic#   NA [13] (🪃..🪏)    <reserved-1FA83>..<reserved-1FA8F>
+1FA90..1FA95  ; Extended_Pictographic# 12.0  [6] (🪐..🪕)    ringed planet..banjo
+1FA96..1FFFD  ; Extended_Pictographic#   NA[1384] (🪖..🿽)   <reserved-1FA96>..<reserved-1FFFD>
+
+# Total elements: 3793
+
+#EOF
index bafad2ae9fa915a947ed24fae8cf2f8c57e6e3a2..abfd49aaae4f4dfab340722a7f51273bbc4228f9 100644 (file)
@@ -98,4 +98,35 @@ defmodule Pleroma.Emoji do
   defp update_emojis(emojis) do
     :ets.insert(@ets, emojis)
   end
+
+  @external_resource "lib/pleroma/emoji-data.txt"
+
+  emojis =
+    @external_resource
+    |> File.read!()
+    |> String.split("\n")
+    |> Enum.filter(fn line -> line != "" and not String.starts_with?(line, "#") end)
+    |> Enum.map(fn line ->
+      line
+      |> String.split(";", parts: 2)
+      |> hd()
+      |> String.trim()
+      |> String.split("..")
+      |> case do
+        [number] ->
+          <<String.to_integer(number, 16)::utf8>>
+
+        [first, last] ->
+          String.to_integer(first, 16)..String.to_integer(last, 16)
+          |> Enum.map(&<<&1::utf8>>)
+      end
+    end)
+    |> List.flatten()
+    |> Enum.uniq()
+
+  for emoji <- emojis do
+    def is_unicode_emoji?(unquote(emoji)), do: true
+  end
+
+  def is_unicode_emoji?(_), do: false
 end
index a1f9c1250b3248c41cc79bf39042bda7b0101143..25aa32f60743dc21fef01e1184539b3b70a77638 100644 (file)
@@ -64,6 +64,8 @@ defmodule Pleroma.Object.Containment do
   def contain_origin(id, %{"attributedTo" => actor} = params),
     do: contain_origin(id, Map.put(params, "actor", actor))
 
+  def contain_origin(_id, _data), do: :error
+
   def contain_origin_from_id(id, %{"id" => other_id} = _params) when is_binary(other_id) do
     id_uri = URI.parse(id)
     other_uri = URI.parse(other_id)
diff --git a/lib/pleroma/plugs/rate_limiter.ex b/lib/pleroma/plugs/rate_limiter.ex
deleted file mode 100644 (file)
index 31388f5..0000000
+++ /dev/null
@@ -1,131 +0,0 @@
-# Pleroma: A lightweight social networking server
-# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
-# SPDX-License-Identifier: AGPL-3.0-only
-
-defmodule Pleroma.Plugs.RateLimiter do
-  @moduledoc """
-
-  ## Configuration
-
-  A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration. The basic configuration is a tuple where:
-
-  * The first element: `scale` (Integer). The time scale in milliseconds.
-  * The second element: `limit` (Integer). How many requests to limit in the time scale provided.
-
-  It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated.
-
-  To disable a limiter set its value to `nil`.
-
-  ### Example
-
-      config :pleroma, :rate_limit,
-        one: {1000, 10},
-        two: [{10_000, 10}, {10_000, 50}],
-        foobar: nil
-
-  Here we have three limiters:
-
-  * `one` which is not over 10req/1s
-  * `two` which has two limits: 10req/10s for unauthenticated users and 50req/10s for authenticated users
-  * `foobar` which is disabled
-
-  ## Usage
-
-  AllowedSyntax:
-
-      plug(Pleroma.Plugs.RateLimiter, :limiter_name)
-      plug(Pleroma.Plugs.RateLimiter, {:limiter_name, options})
-
-  Allowed options:
-
-      * `bucket_name` overrides bucket name (e.g. to have a separate limit for a set of actions)
-      * `params` appends values of specified request params (e.g. ["id"]) to bucket name
-
-  Inside a controller:
-
-      plug(Pleroma.Plugs.RateLimiter, :one when action == :one)
-      plug(Pleroma.Plugs.RateLimiter, :two when action in [:two, :three])
-
-      plug(
-        Pleroma.Plugs.RateLimiter,
-        {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]}
-        when action in ~w(fav_status unfav_status)a
-      )
-
-  or inside a router pipeline:
-
-      pipeline :api do
-        ...
-        plug(Pleroma.Plugs.RateLimiter, :one)
-        ...
-      end
-  """
-  import Pleroma.Web.TranslationHelpers
-  import Plug.Conn
-
-  alias Pleroma.User
-
-  def init(limiter_name) when is_atom(limiter_name) do
-    init({limiter_name, []})
-  end
-
-  def init({limiter_name, opts}) do
-    case Pleroma.Config.get([:rate_limit, limiter_name]) do
-      nil -> nil
-      config -> {limiter_name, config, opts}
-    end
-  end
-
-  # Do not limit if there is no limiter configuration
-  def call(conn, nil), do: conn
-
-  def call(conn, settings) do
-    case check_rate(conn, settings) do
-      {:ok, _count} ->
-        conn
-
-      {:error, _count} ->
-        render_throttled_error(conn)
-    end
-  end
-
-  defp bucket_name(conn, limiter_name, opts) do
-    bucket_name = opts[:bucket_name] || limiter_name
-
-    if params_names = opts[:params] do
-      params_values = for p <- Enum.sort(params_names), do: conn.params[p]
-      Enum.join([bucket_name] ++ params_values, ":")
-    else
-      bucket_name
-    end
-  end
-
-  defp check_rate(
-         %{assigns: %{user: %User{id: user_id}}} = conn,
-         {limiter_name, [_, {scale, limit}], opts}
-       ) do
-    bucket_name = bucket_name(conn, limiter_name, opts)
-    ExRated.check_rate("#{bucket_name}:#{user_id}", scale, limit)
-  end
-
-  defp check_rate(conn, {limiter_name, [{scale, limit} | _], opts}) do
-    bucket_name = bucket_name(conn, limiter_name, opts)
-    ExRated.check_rate("#{bucket_name}:#{ip(conn)}", scale, limit)
-  end
-
-  defp check_rate(conn, {limiter_name, {scale, limit}, opts}) do
-    check_rate(conn, {limiter_name, [{scale, limit}, {scale, limit}], opts})
-  end
-
-  def ip(%{remote_ip: remote_ip}) do
-    remote_ip
-    |> Tuple.to_list()
-    |> Enum.join(".")
-  end
-
-  defp render_throttled_error(conn) do
-    conn
-    |> render_error(:too_many_requests, "Throttled")
-    |> halt()
-  end
-end
diff --git a/lib/pleroma/plugs/rate_limiter/limiter_supervisor.ex b/lib/pleroma/plugs/rate_limiter/limiter_supervisor.ex
new file mode 100644 (file)
index 0000000..187582e
--- /dev/null
@@ -0,0 +1,44 @@
+defmodule Pleroma.Plugs.RateLimiter.LimiterSupervisor do
+  use DynamicSupervisor
+
+  import Cachex.Spec
+
+  def start_link(init_arg) do
+    DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
+  end
+
+  def add_limiter(limiter_name, expiration) do
+    {:ok, _pid} =
+      DynamicSupervisor.start_child(
+        __MODULE__,
+        %{
+          id: String.to_atom("rl_#{limiter_name}"),
+          start:
+            {Cachex, :start_link,
+             [
+               limiter_name,
+               [
+                 expiration:
+                   expiration(
+                     default: expiration,
+                     interval: check_interval(expiration),
+                     lazy: true
+                   )
+               ]
+             ]}
+        }
+      )
+  end
+
+  @impl true
+  def init(_init_arg) do
+    DynamicSupervisor.init(strategy: :one_for_one)
+  end
+
+  defp check_interval(exp) do
+    (exp / 2)
+    |> Kernel.trunc()
+    |> Kernel.min(5000)
+    |> Kernel.max(1)
+  end
+end
diff --git a/lib/pleroma/plugs/rate_limiter/rate_limiter.ex b/lib/pleroma/plugs/rate_limiter/rate_limiter.ex
new file mode 100644 (file)
index 0000000..d720508
--- /dev/null
@@ -0,0 +1,227 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Plugs.RateLimiter do
+  @moduledoc """
+
+  ## Configuration
+
+  A keyword list of rate limiters where a key is a limiter name and value is the limiter configuration. The basic configuration is a tuple where:
+
+  * The first element: `scale` (Integer). The time scale in milliseconds.
+  * The second element: `limit` (Integer). How many requests to limit in the time scale provided.
+
+  It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated.
+
+  To disable a limiter set its value to `nil`.
+
+  ### Example
+
+      config :pleroma, :rate_limit,
+        one: {1000, 10},
+        two: [{10_000, 10}, {10_000, 50}],
+        foobar: nil
+
+  Here we have three limiters:
+
+  * `one` which is not over 10req/1s
+  * `two` which has two limits: 10req/10s for unauthenticated users and 50req/10s for authenticated users
+  * `foobar` which is disabled
+
+  ## Usage
+
+  AllowedSyntax:
+
+      plug(Pleroma.Plugs.RateLimiter, name: :limiter_name)
+      plug(Pleroma.Plugs.RateLimiter, options)   # :name is a required option
+
+  Allowed options:
+
+      * `name` required, always used to fetch the limit values from the config
+      * `bucket_name` overrides name for counting purposes (e.g. to have a separate limit for a set of actions)
+      * `params` appends values of specified request params (e.g. ["id"]) to bucket name
+
+  Inside a controller:
+
+      plug(Pleroma.Plugs.RateLimiter, [name: :one] when action == :one)
+      plug(Pleroma.Plugs.RateLimiter, [name: :two] when action in [:two, :three])
+
+      plug(
+        Pleroma.Plugs.RateLimiter,
+        [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]]
+        when action in ~w(fav_status unfav_status)a
+      )
+
+  or inside a router pipeline:
+
+      pipeline :api do
+        ...
+        plug(Pleroma.Plugs.RateLimiter, name: :one)
+        ...
+      end
+  """
+  import Pleroma.Web.TranslationHelpers
+  import Plug.Conn
+
+  alias Pleroma.Plugs.RateLimiter.LimiterSupervisor
+  alias Pleroma.User
+
+  def init(opts) do
+    limiter_name = Keyword.get(opts, :name)
+
+    case Pleroma.Config.get([:rate_limit, limiter_name]) do
+      nil ->
+        nil
+
+      config ->
+        name_root = Keyword.get(opts, :bucket_name, limiter_name)
+
+        %{
+          name: name_root,
+          limits: config,
+          opts: opts
+        }
+    end
+  end
+
+  # Do not limit if there is no limiter configuration
+  def call(conn, nil), do: conn
+
+  def call(conn, settings) do
+    settings
+    |> incorporate_conn_info(conn)
+    |> check_rate()
+    |> case do
+      {:ok, _count} ->
+        conn
+
+      {:error, _count} ->
+        render_throttled_error(conn)
+    end
+  end
+
+  def inspect_bucket(conn, name_root, settings) do
+    settings =
+      settings
+      |> incorporate_conn_info(conn)
+
+    bucket_name = make_bucket_name(%{settings | name: name_root})
+    key_name = make_key_name(settings)
+    limit = get_limits(settings)
+
+    case Cachex.get(bucket_name, key_name) do
+      {:error, :no_cache} ->
+        {:err, :not_found}
+
+      {:ok, nil} ->
+        {0, limit}
+
+      {:ok, value} ->
+        {value, limit - value}
+    end
+  end
+
+  defp check_rate(settings) do
+    bucket_name = make_bucket_name(settings)
+    key_name = make_key_name(settings)
+    limit = get_limits(settings)
+
+    case Cachex.get_and_update(bucket_name, key_name, &increment_value(&1, limit)) do
+      {:commit, value} ->
+        {:ok, value}
+
+      {:ignore, value} ->
+        {:error, value}
+
+      {:error, :no_cache} ->
+        initialize_buckets(settings)
+        check_rate(settings)
+    end
+  end
+
+  defp increment_value(nil, _limit), do: {:commit, 1}
+
+  defp increment_value(val, limit) when val >= limit, do: {:ignore, val}
+
+  defp increment_value(val, _limit), do: {:commit, val + 1}
+
+  defp incorporate_conn_info(settings, %{assigns: %{user: %User{id: user_id}}, params: params}) do
+    Map.merge(settings, %{
+      mode: :user,
+      conn_params: params,
+      conn_info: "#{user_id}"
+    })
+  end
+
+  defp incorporate_conn_info(settings, %{params: params} = conn) do
+    Map.merge(settings, %{
+      mode: :anon,
+      conn_params: params,
+      conn_info: "#{ip(conn)}"
+    })
+  end
+
+  defp ip(%{remote_ip: remote_ip}) do
+    remote_ip
+    |> Tuple.to_list()
+    |> Enum.join(".")
+  end
+
+  defp render_throttled_error(conn) do
+    conn
+    |> render_error(:too_many_requests, "Throttled")
+    |> halt()
+  end
+
+  defp make_key_name(settings) do
+    ""
+    |> attach_params(settings)
+    |> attach_identity(settings)
+  end
+
+  defp get_scale(_, {scale, _}), do: scale
+
+  defp get_scale(:anon, [{scale, _}, {_, _}]), do: scale
+
+  defp get_scale(:user, [{_, _}, {scale, _}]), do: scale
+
+  defp get_limits(%{limits: {_scale, limit}}), do: limit
+
+  defp get_limits(%{mode: :user, limits: [_, {_, limit}]}), do: limit
+
+  defp get_limits(%{limits: [{_, limit}, _]}), do: limit
+
+  defp make_bucket_name(%{mode: :user, name: name_root}),
+    do: user_bucket_name(name_root)
+
+  defp make_bucket_name(%{mode: :anon, name: name_root}),
+    do: anon_bucket_name(name_root)
+
+  defp attach_params(input, %{conn_params: conn_params, opts: opts}) do
+    param_string =
+      opts
+      |> Keyword.get(:params, [])
+      |> Enum.sort()
+      |> Enum.map(&Map.get(conn_params, &1, ""))
+      |> Enum.join(":")
+
+    "#{input}#{param_string}"
+  end
+
+  defp initialize_buckets(%{name: _name, limits: nil}), do: :ok
+
+  defp initialize_buckets(%{name: name, limits: limits}) do
+    LimiterSupervisor.add_limiter(anon_bucket_name(name), get_scale(:anon, limits))
+    LimiterSupervisor.add_limiter(user_bucket_name(name), get_scale(:user, limits))
+  end
+
+  defp attach_identity(base, %{mode: :user, conn_info: conn_info}),
+    do: "user:#{base}:#{conn_info}"
+
+  defp attach_identity(base, %{mode: :anon, conn_info: conn_info}),
+    do: "ip:#{base}:#{conn_info}"
+
+  defp user_bucket_name(name_root), do: "user:#{name_root}" |> String.to_atom()
+  defp anon_bucket_name(name_root), do: "anon:#{name_root}" |> String.to_atom()
+end
diff --git a/lib/pleroma/plugs/rate_limiter/supervisor.ex b/lib/pleroma/plugs/rate_limiter/supervisor.ex
new file mode 100644 (file)
index 0000000..9672f78
--- /dev/null
@@ -0,0 +1,16 @@
+defmodule Pleroma.Plugs.RateLimiter.Supervisor do
+  use Supervisor
+
+  def start_link(opts) do
+    Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
+  end
+
+  def init(_args) do
+    children = [
+      Pleroma.Plugs.RateLimiter.LimiterSupervisor
+    ]
+
+    opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor]
+    Supervisor.init(children, opts)
+  end
+end
diff --git a/lib/pleroma/plugs/static_fe_plug.ex b/lib/pleroma/plugs/static_fe_plug.ex
new file mode 100644 (file)
index 0000000..b3fb3c5
--- /dev/null
@@ -0,0 +1,26 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Plugs.StaticFEPlug do
+  import Plug.Conn
+  alias Pleroma.Web.StaticFE.StaticFEController
+
+  def init(options), do: options
+
+  def call(conn, _) do
+    if enabled?() and accepts_html?(conn) do
+      conn
+      |> StaticFEController.call(:show)
+      |> halt()
+    else
+      conn
+    end
+  end
+
+  defp enabled?, do: Pleroma.Config.get([:static_fe, :enabled], false)
+
+  defp accepts_html?(conn) do
+    conn |> get_req_header("accept") |> List.first() |> String.contains?("text/html")
+  end
+end
index 65dd251f3ea3ddb62ab6b5fa2460e41db288e162..d0c014e9dba96052e96e9719db35b33f7a9a924e 100644 (file)
@@ -322,6 +322,32 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
+  def react_with_emoji(user, object, emoji, options \\ []) do
+    with local <- Keyword.get(options, :local, true),
+         activity_id <- Keyword.get(options, :activity_id, nil),
+         Pleroma.Emoji.is_unicode_emoji?(emoji),
+         reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id),
+         {:ok, activity} <- insert(reaction_data, local),
+         {:ok, object} <- add_emoji_reaction_to_object(activity, object),
+         :ok <- maybe_federate(activity) do
+      {:ok, activity, object}
+    end
+  end
+
+  def unreact_with_emoji(user, reaction_id, options \\ []) do
+    with local <- Keyword.get(options, :local, true),
+         activity_id <- Keyword.get(options, :activity_id, nil),
+         user_ap_id <- user.ap_id,
+         %Activity{actor: ^user_ap_id} = reaction_activity <- Activity.get_by_ap_id(reaction_id),
+         object <- Object.normalize(reaction_activity),
+         unreact_data <- make_undo_data(user, reaction_activity, activity_id),
+         {:ok, activity} <- insert(unreact_data, local),
+         {:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object),
+         :ok <- maybe_federate(activity) do
+      {:ok, activity, object}
+    end
+  end
+
   # TODO: This is weird, maybe we shouldn't check here if we can make the activity.
   def like(
         %User{ap_id: ap_id} = user,
diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex
new file mode 100644 (file)
index 0000000..8b36c10
--- /dev/null
@@ -0,0 +1,101 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do
+  alias Pleroma.Config
+  alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.MRF
+
+  require Pleroma.Constants
+
+  @moduledoc "Filter activities depending on their age"
+  @behaviour MRF
+
+  defp check_date(%{"published" => published} = message) do
+    with %DateTime{} = now <- DateTime.utc_now(),
+         {:ok, %DateTime{} = then, _} <- DateTime.from_iso8601(published),
+         max_ttl <- Config.get([:mrf_object_age, :threshold]),
+         {:ttl, false} <- {:ttl, DateTime.diff(now, then) > max_ttl} do
+      {:ok, message}
+    else
+      {:ttl, true} ->
+        {:reject, nil}
+
+      e ->
+        {:error, e}
+    end
+  end
+
+  defp check_reject(message, actions) do
+    if :reject in actions do
+      {:reject, nil}
+    else
+      {:ok, message}
+    end
+  end
+
+  defp check_delist(message, actions) do
+    if :delist in actions do
+      with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do
+        to = List.delete(message["to"], Pleroma.Constants.as_public()) ++ [user.follower_address]
+        cc = List.delete(message["cc"], user.follower_address) ++ [Pleroma.Constants.as_public()]
+
+        message =
+          message
+          |> Map.put("to", to)
+          |> Map.put("cc", cc)
+
+        {:ok, message}
+      else
+        # Unhandleable error: somebody is messing around, just drop the message.
+        _e ->
+          {:reject, nil}
+      end
+    else
+      {:ok, message}
+    end
+  end
+
+  defp check_strip_followers(message, actions) do
+    if :strip_followers in actions do
+      with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do
+        to = List.delete(message["to"], user.follower_address)
+        cc = List.delete(message["cc"], user.follower_address)
+
+        message =
+          message
+          |> Map.put("to", to)
+          |> Map.put("cc", cc)
+
+        {:ok, message}
+      else
+        # Unhandleable error: somebody is messing around, just drop the message.
+        _e ->
+          {:reject, nil}
+      end
+    else
+      {:ok, message}
+    end
+  end
+
+  @impl true
+  def filter(%{"type" => "Create", "published" => _} = message) do
+    with actions <- Config.get([:mrf_object_age, :actions]),
+         {:reject, _} <- check_date(message),
+         {:ok, message} <- check_reject(message, actions),
+         {:ok, message} <- check_delist(message, actions),
+         {:ok, message} <- check_strip_followers(message, actions) do
+      {:ok, message}
+    else
+      # check_date() is allowed to short-circuit the pipeline
+      e -> e
+    end
+  end
+
+  @impl true
+  def filter(message), do: {:ok, message}
+
+  @impl true
+  def describe, do: {:ok, %{}}
+end
index 91a164eff34055e7bcaf368133a7ae70c8ff29fb..15612545bd376fd97daddb81d2dc11e489683332 100644 (file)
@@ -566,6 +566,34 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     end
   end
 
+  @misskey_reactions %{
+    "like" => "👍",
+    "love" => "❤️",
+    "laugh" => "😆",
+    "hmm" => "🤔",
+    "surprise" => "😮",
+    "congrats" => "🎉",
+    "angry" => "💢",
+    "confused" => "😥",
+    "rip" => "😇",
+    "pudding" => "🍮",
+    "star" => "⭐"
+  }
+
+  @doc "Rewrite misskey likes into EmojiReactions"
+  def handle_incoming(
+        %{
+          "type" => "Like",
+          "_misskey_reaction" => reaction
+        } = data,
+        options
+      ) do
+    data
+    |> Map.put("type", "EmojiReaction")
+    |> Map.put("content", @misskey_reactions[reaction] || reaction)
+    |> handle_incoming(options)
+  end
+
   def handle_incoming(
         %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data,
         _options
@@ -580,6 +608,27 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     end
   end
 
+  def handle_incoming(
+        %{
+          "type" => "EmojiReaction",
+          "object" => object_id,
+          "actor" => _actor,
+          "id" => id,
+          "content" => emoji
+        } = data,
+        _options
+      ) do
+    with actor <- Containment.get_actor(data),
+         {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
+         {:ok, object} <- get_obj_helper(object_id),
+         {:ok, activity, _object} <-
+           ActivityPub.react_with_emoji(actor, object, emoji, activity_id: id, local: false) do
+      {:ok, activity}
+    else
+      _e -> :error
+    end
+  end
+
   def handle_incoming(
         %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data,
         _options
@@ -715,6 +764,28 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     end
   end
 
+  def handle_incoming(
+        %{
+          "type" => "Undo",
+          "object" => %{"type" => "EmojiReaction", "id" => reaction_activity_id},
+          "actor" => _actor,
+          "id" => id
+        } = data,
+        _options
+      ) do
+    with actor <- Containment.get_actor(data),
+         {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
+         {:ok, activity, _} <-
+           ActivityPub.unreact_with_emoji(actor, reaction_activity_id,
+             activity_id: id,
+             local: false
+           ) do
+      {:ok, activity}
+    else
+      _e -> :error
+    end
+  end
+
   def handle_incoming(
         %{
           "type" => "Undo",
@@ -1048,7 +1119,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     Map.put(object, "attachment", attachments)
   end
 
-  defp strip_internal_fields(object) do
+  def strip_internal_fields(object) do
     object
     |> Map.drop(Pleroma.Constants.object_internal_fields())
   end
index d812fd734292cf9d3b481cbfe2dc520517653a3f..c456623596b372c91084c06adde6ffb259592579 100644 (file)
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do
   alias Pleroma.Repo
   alias Pleroma.User
   alias Pleroma.Web
+  alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.ActivityPub.Visibility
   alias Pleroma.Web.AdminAPI.AccountView
   alias Pleroma.Web.Endpoint
@@ -255,6 +256,16 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     |> Repo.one()
   end
 
+  @doc """
+  Returns like activities targeting an object
+  """
+  def get_object_likes(%{data: %{"id" => id}}) do
+    id
+    |> Activity.Queries.by_object_id()
+    |> Activity.Queries.by_type("Like")
+    |> Repo.all()
+  end
+
   @spec make_like_data(User.t(), map(), String.t()) :: map()
   def make_like_data(
         %User{ap_id: ap_id} = actor,
@@ -286,13 +297,30 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     |> maybe_put("id", activity_id)
   end
 
+  def make_emoji_reaction_data(user, object, emoji, activity_id) do
+    make_like_data(user, object, activity_id)
+    |> Map.put("type", "EmojiReaction")
+    |> Map.put("content", emoji)
+  end
+
   @spec update_element_in_object(String.t(), list(any), Object.t()) ::
           {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
   def update_element_in_object(property, element, object) do
+    length =
+      if is_map(element) do
+        element
+        |> Map.values()
+        |> List.flatten()
+        |> length()
+      else
+        element
+        |> length()
+      end
+
     data =
       Map.merge(
         object.data,
-        %{"#{property}_count" => length(element), "#{property}s" => element}
+        %{"#{property}_count" => length, "#{property}s" => element}
       )
 
     object
@@ -300,6 +328,38 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     |> Object.update_and_set_cache()
   end
 
+  @spec add_emoji_reaction_to_object(Activity.t(), Object.t()) ::
+          {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
+
+  def add_emoji_reaction_to_object(
+        %Activity{data: %{"content" => emoji, "actor" => actor}},
+        object
+      ) do
+    reactions = object.data["reactions"] || %{}
+    emoji_actors = reactions[emoji] || []
+    new_emoji_actors = [actor | emoji_actors] |> Enum.uniq()
+    new_reactions = Map.put(reactions, emoji, new_emoji_actors)
+    update_element_in_object("reaction", new_reactions, object)
+  end
+
+  def remove_emoji_reaction_from_object(
+        %Activity{data: %{"content" => emoji, "actor" => actor}},
+        object
+      ) do
+    reactions = object.data["reactions"] || %{}
+    emoji_actors = reactions[emoji] || []
+    new_emoji_actors = List.delete(emoji_actors, actor)
+
+    new_reactions =
+      if new_emoji_actors == [] do
+        Map.delete(reactions, emoji)
+      else
+        Map.put(reactions, emoji, new_emoji_actors)
+      end
+
+    update_element_in_object("reaction", new_reactions, object)
+  end
+
   @spec add_like_to_object(Activity.t(), Object.t()) ::
           {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
   def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
@@ -397,6 +457,19 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     |> Repo.one()
   end
 
+  def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do
+    %{data: %{"object" => object_ap_id}} = Activity.get_by_id(internal_activity_id)
+
+    "EmojiReaction"
+    |> Activity.Queries.by_type()
+    |> where(actor: ^ap_id)
+    |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji))
+    |> Activity.Queries.by_object_id(object_ap_id)
+    |> order_by([activity], fragment("? desc nulls last", activity.id))
+    |> limit(1)
+    |> Repo.one()
+  end
+
   #### Announce-related helpers
 
   @doc """
@@ -489,6 +562,25 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     |> maybe_put("id", activity_id)
   end
 
+  def make_undo_data(
+        %User{ap_id: actor, follower_address: follower_address},
+        %Activity{
+          data: %{"id" => undone_activity_id, "context" => context},
+          actor: undone_activity_actor
+        },
+        activity_id \\ nil
+      ) do
+    %{
+      "type" => "Undo",
+      "actor" => actor,
+      "object" => undone_activity_id,
+      "to" => [follower_address, undone_activity_actor],
+      "cc" => [Pleroma.Constants.as_public()],
+      "context" => context
+    }
+    |> maybe_put("id", activity_id)
+  end
+
   @spec add_announce_to_object(Activity.t(), Object.t()) ::
           {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
   def add_announce_to_object(
@@ -615,26 +707,31 @@ defmodule Pleroma.Web.ActivityPub.Utils do
   def make_flag_data(_, _), do: %{}
 
   defp build_flag_object(%{account: account, statuses: statuses} = _) do
-    [account.ap_id] ++
-      Enum.map(statuses || [], fn act ->
-        id =
-          case act do
-            %Activity{} = act -> act.data["id"]
-            act when is_map(act) -> act["id"]
-            act when is_binary(act) -> act
-          end
+    [account.ap_id] ++ build_flag_object(%{statuses: statuses})
+  end
+
+  defp build_flag_object(%{statuses: statuses}) do
+    Enum.map(statuses || [], &build_flag_object/1)
+  end
 
-        activity = Activity.get_by_ap_id_with_object(id)
-        actor = User.get_by_ap_id(activity.object.data["actor"])
+  defp build_flag_object(act) when is_map(act) or is_binary(act) do
+    id =
+      case act do
+        %Activity{} = act -> act.data["id"]
+        act when is_map(act) -> act["id"]
+        act when is_binary(act) -> act
+      end
 
-        %{
-          "type" => "Note",
-          "id" => activity.data["id"],
-          "content" => activity.object.data["content"],
-          "published" => activity.object.data["published"],
-          "actor" => AccountView.render("show.json", %{user: actor})
-        }
-      end)
+    activity = Activity.get_by_ap_id_with_object(id)
+    actor = User.get_by_ap_id(activity.object.data["actor"])
+
+    %{
+      "type" => "Note",
+      "id" => activity.data["id"],
+      "content" => activity.object.data["content"],
+      "published" => activity.object.data["published"],
+      "actor" => AccountView.render("show.json", %{user: actor})
+    }
   end
 
   defp build_flag_object(_), do: []
@@ -679,6 +776,94 @@ defmodule Pleroma.Web.ActivityPub.Utils do
   end
 
   #### Report-related helpers
+  def get_reports(params, page, page_size) do
+    params =
+      params
+      |> Map.put("type", "Flag")
+      |> Map.put("skip_preload", true)
+      |> Map.put("total", true)
+      |> Map.put("limit", page_size)
+      |> Map.put("offset", (page - 1) * page_size)
+
+    ActivityPub.fetch_activities([], params, :offset)
+  end
+
+  @spec get_reports_grouped_by_status(%{required(:activity) => String.t()}) :: %{
+          required(:groups) => [
+            %{
+              required(:date) => String.t(),
+              required(:account) => %{},
+              required(:status) => %{},
+              required(:actors) => [%User{}],
+              required(:reports) => [%Activity{}]
+            }
+          ],
+          required(:total) => integer
+        }
+  def get_reports_grouped_by_status(groups) do
+    parsed_groups =
+      groups
+      |> Enum.map(fn entry ->
+        activity =
+          case Jason.decode(entry.activity) do
+            {:ok, activity} -> activity
+            _ -> build_flag_object(entry.activity)
+          end
+
+        parse_report_group(activity)
+      end)
+
+    %{
+      groups: parsed_groups
+    }
+  end
+
+  def parse_report_group(activity) do
+    reports = get_reports_by_status_id(activity["id"])
+    max_date = Enum.max_by(reports, &NaiveDateTime.from_iso8601!(&1.data["published"]))
+    actors = Enum.map(reports, & &1.user_actor)
+
+    %{
+      date: max_date.data["published"],
+      account: activity["actor"],
+      status: %{
+        id: activity["id"],
+        content: activity["content"],
+        published: activity["published"]
+      },
+      actors: Enum.uniq(actors),
+      reports: reports
+    }
+  end
+
+  def get_reports_by_status_id(ap_id) do
+    from(a in Activity,
+      where: fragment("(?)->>'type' = 'Flag'", a.data),
+      where: fragment("(?)->'object' @> ?", a.data, ^[%{id: ap_id}])
+    )
+    |> Activity.with_preloaded_user_actor()
+    |> Repo.all()
+  end
+
+  @spec get_reported_activities() :: [
+          %{
+            required(:activity) => String.t(),
+            required(:date) => String.t()
+          }
+        ]
+  def get_reported_activities do
+    from(a in Activity,
+      where: fragment("(?)->>'type' = 'Flag'", a.data),
+      select: %{
+        date: fragment("max(?->>'published') date", a.data),
+        activity:
+          fragment("jsonb_array_elements_text((? #- '{object,0}')->'object') activity", a.data)
+      },
+      group_by: fragment("activity"),
+      order_by: fragment("date DESC")
+    )
+    |> Repo.all()
+  end
 
   def update_report_state(%Activity{} = activity, state)
       when state in @strip_status_report_states do
@@ -702,6 +887,18 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     |> Repo.update()
   end
 
+  def update_report_state(activity_ids, state) when state in @supported_report_states do
+    activities_num = length(activity_ids)
+
+    from(a in Activity, where: a.id in ^activity_ids)
+    |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)])
+    |> Repo.update_all([])
+    |> case do
+      {^activities_num, _} -> :ok
+      _ -> {:error, activity_ids}
+    end
+  end
+
   def update_report_state(_, _), do: {:error, "Unsupported state"}
 
   def strip_report_status_data(activity) do
index 30fc017558e2e4d39182e96a11246f365884ac6b..8c1318d1bb2821f40c2797eedbe55e019637490a 100644 (file)
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   alias Pleroma.UserInviteToken
   alias Pleroma.Web.ActivityPub.ActivityPub
   alias Pleroma.Web.ActivityPub.Relay
+  alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.AdminAPI.AccountView
   alias Pleroma.Web.AdminAPI.Config
   alias Pleroma.Web.AdminAPI.ConfigView
@@ -624,19 +625,17 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
   def list_reports(conn, params) do
     {page, page_size} = page_params(params)
 
-    params =
-      params
-      |> Map.put("type", "Flag")
-      |> Map.put("skip_preload", true)
-      |> Map.put("total", true)
-      |> Map.put("limit", page_size)
-      |> Map.put("offset", (page - 1) * page_size)
+    conn
+    |> put_view(ReportView)
+    |> render("index.json", %{reports: Utils.get_reports(params, page, page_size)})
+  end
 
-    reports = ActivityPub.fetch_activities([], params, :offset)
+  def list_grouped_reports(conn, _params) do
+    reports = Utils.get_reported_activities()
 
     conn
     |> put_view(ReportView)
-    |> render("index.json", %{reports: reports})
+    |> render("index_grouped.json", Utils.get_reports_grouped_by_status(reports))
   end
 
   def report_show(conn, %{"id" => id}) do
@@ -649,17 +648,26 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
     end
   end
 
-  def report_update_state(%{assigns: %{user: admin}} = conn, %{"id" => id, "state" => state}) do
-    with {:ok, report} <- CommonAPI.update_report_state(id, state) do
-      ModerationLog.insert_log(%{
-        action: "report_update",
-        actor: admin,
-        subject: report
-      })
+  def reports_update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do
+    result =
+      reports
+      |> Enum.map(fn report ->
+        with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do
+          ModerationLog.insert_log(%{
+            action: "report_update",
+            actor: admin,
+            subject: activity
+          })
+
+          activity
+        else
+          {:error, message} -> %{id: report["id"], error: message}
+        end
+      end)
 
-      conn
-      |> put_view(ReportView)
-      |> render("show.json", Report.extract_report_info(report))
+    case Enum.any?(result, &Map.has_key?(&1, :error)) do
+      true -> json_response(conn, :bad_request, result)
+      false -> json_response(conn, :no_content, "")
     end
   end
 
index 101a74c630833ad17c123c355e32c9563097b1b8..ca88595c722054c4cb1155aca19aea52c5bb1584 100644 (file)
@@ -42,6 +42,26 @@ defmodule Pleroma.Web.AdminAPI.ReportView do
     }
   end
 
+  def render("index_grouped.json", %{groups: groups}) do
+    reports =
+      Enum.map(groups, fn group ->
+        %{
+          date: group[:date],
+          account: group[:account],
+          status: group[:status],
+          actors: Enum.map(group[:actors], &merge_account_views/1),
+          reports:
+            group[:reports]
+            |> Enum.map(&Report.extract_report_info(&1))
+            |> Enum.map(&render(__MODULE__, "show.json", &1))
+        }
+      end)
+
+    %{
+      reports: reports
+    }
+  end
+
   defp merge_account_views(%User{} = user) do
     Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user})
     |> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user}))
index e5734562145769ab205f3ecd3249b291ea8caf54..fe6e26a90ab6dee48c35df65acfe1f16670638fa 100644 (file)
@@ -120,6 +120,25 @@ defmodule Pleroma.Web.CommonAPI do
     end
   end
 
+  def react_with_emoji(id, user, emoji) do
+    with %Activity{} = activity <- Activity.get_by_id(id),
+         object <- Object.normalize(activity) do
+      ActivityPub.react_with_emoji(user, object, emoji)
+    else
+      _ ->
+        {:error, dgettext("errors", "Could not add reaction emoji")}
+    end
+  end
+
+  def unreact_with_emoji(id, user, emoji) do
+    with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji) do
+      ActivityPub.unreact_with_emoji(user, reaction_activity.data["id"])
+    else
+      _ ->
+        {:error, dgettext("errors", "Could not remove reaction emoji")}
+    end
+  end
+
   def vote(user, %{data: %{"type" => "Question"}} = object, choices) do
     with :ok <- validate_not_author(object, user),
          :ok <- validate_existing_votes(user, object),
@@ -351,6 +370,13 @@ defmodule Pleroma.Web.CommonAPI do
     end
   end
 
+  def update_report_state(activity_ids, state) when is_list(activity_ids) do
+    case Utils.update_report_state(activity_ids, state) do
+      :ok -> {:ok, activity_ids}
+      _ -> {:error, dgettext("errors", "Could not update state")}
+    end
+  end
+
   def update_report_state(activity_id, state) do
     with %Activity{} = activity <- Activity.get_by_id(activity_id) do
       Utils.update_report_state(activity, state)
index 73fad519ecbe1259f3ab0782beaddbf818943bcc..5b01b964b8a52fb85aa05a60de590df627a9f87a 100644 (file)
@@ -66,9 +66,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
   @relations [:follow, :unfollow]
   @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a
 
-  plug(RateLimiter, {:relations_id_action, params: ["id", "uri"]} when action in @relations)
-  plug(RateLimiter, :relations_actions when action in @relations)
-  plug(RateLimiter, :app_account_creation when action == :create)
+  plug(RateLimiter, [name: :relations_id_action, params: ["id", "uri"]] when action in @relations)
+  plug(RateLimiter, [name: :relations_actions] when action in @relations)
+  plug(RateLimiter, [name: :app_account_creation] when action == :create)
   plug(:assign_account_by_id when action in @needs_account)
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
index bfd5120ba4bfb2ffa38cf2827c3ade35d5cced20..d9e51de7f27757c54d2ea2701669bec692052517 100644 (file)
@@ -15,7 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do
 
   @local_mastodon_name "Mastodon-Local"
 
-  plug(Pleroma.Plugs.RateLimiter, :password_reset when action == :password_reset)
+  plug(Pleroma.Plugs.RateLimiter, [name: :password_reset] when action == :password_reset)
 
   @doc "GET /web/login"
   def login(%{assigns: %{user: %User{}}} = conn, _params) do
index 6cfd68a84b0d60e16fd2c3bbb6a0b42ee7e5f0ff..0a929f55b9137aa87ff57fdbfab750a68cec7c59 100644 (file)
@@ -22,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
 
   plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
 
-  plug(RateLimiter, :search when action in [:search, :search2, :account_search])
+  plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
 
   def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do
     accounts = User.search(query, search_options(params, user))
index e5d016f63711dc0f899172448ddbef138a5f4cbb..74b223cf4efcfa1771c999c364bf80bcc808aae0 100644 (file)
@@ -82,17 +82,17 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
 
   plug(
     RateLimiter,
-    {:status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]}
+    [name: :status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]]
     when action in ~w(reblog unreblog)a
   )
 
   plug(
     RateLimiter,
-    {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]}
+    [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]]
     when action in ~w(favourite unfavourite)a
   )
 
-  plug(RateLimiter, :statuses_actions when action in @rate_limited_status_actions)
+  plug(RateLimiter, [name: :statuses_actions] when action in @rate_limited_status_actions)
 
   action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
 
index c5998e6611d2c0917016353549479e002a0b1108..2220fbcb18e327673926e41ecfd0b4309f674223 100644 (file)
@@ -12,7 +12,10 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do
   alias Pleroma.Web.MastodonAPI.StatusView
 
   def render("participations.json", %{participations: participations, for: user}) do
-    render_many(participations, __MODULE__, "participation.json", as: :participation, for: user)
+    safe_render_many(participations, __MODULE__, "participation.json", %{
+      as: :participation,
+      for: user
+    })
   end
 
   def render("participation.json", %{participation: participation, for: user}) do
index 6ed181cffb78e43db51ba626837e482a086b10f0..358600e7d1ff34c0bc308fb80f4c831b6e10a459 100644 (file)
@@ -10,8 +10,8 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do
   alias Pleroma.Repo
   alias Pleroma.User
 
-  plug(RateLimiter, :authentication when action in [:user_exists, :check_password])
-  plug(RateLimiter, {:authentication, params: ["user"]} when action == :check_password)
+  plug(RateLimiter, [name: :authentication] when action in [:user_exists, :check_password])
+  plug(RateLimiter, [name: :authentication, params: ["user"]] when action == :check_password)
 
   def user_exists(conn, %{"user" => username}) do
     with %User{} <- Repo.get_by(User, nickname: username, local: true) do
index d7ae503f60ec16dc0db7e126a349f476fdaeceaf..486b9f6a4dce7d685a92e08ea42de1610f7445f5 100644 (file)
@@ -46,6 +46,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do
 
         data
         |> Map.merge(%{quarantined_instances: quarantined})
+        |> Map.put(:enabled, Config.get([:instance, :federating]))
       else
         %{}
       end
index fe71aca8cea95f26f5523a439674ce620aa30cbd..2aee8cab2bab4c384f205c04a96c499764514e97 100644 (file)
@@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   use Pleroma.Web, :controller
 
   alias Pleroma.Helpers.UriHelper
+  alias Pleroma.Plugs.RateLimiter
   alias Pleroma.Registration
   alias Pleroma.Repo
   alias Pleroma.User
@@ -24,7 +25,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
 
   plug(:fetch_session)
   plug(:fetch_flash)
-  plug(Pleroma.Plugs.RateLimiter, :authentication when action == :create_authorization)
+  plug(RateLimiter, [name: :authentication] when action == :create_authorization)
 
   action_fallback(Pleroma.Web.OAuth.FallbackController)
 
@@ -36,7 +37,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
     authorize(conn, Map.merge(params, auth_attrs))
   end
 
-  def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, params) do
+  def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, %{"force_login" => _} = params) do
     if ControllerHelper.truthy_param?(params["force_login"]) do
       do_authorize(conn, params)
     else
@@ -44,6 +45,22 @@ defmodule Pleroma.Web.OAuth.OAuthController do
     end
   end
 
+  # Note: the token is set in oauth_plug, but the token and client do not always go together.
+  # For example, MastodonFE's token is set if user requests with another client,
+  # after user already authorized to MastodonFE.
+  # So we have to check client and token.
+  def authorize(
+        %Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
+        %{"client_id" => client_id} = params
+      ) do
+    with %Token{} = t <- Repo.get_by(Token, token: token.token) |> Repo.preload(:app),
+         ^client_id <- t.app.client_id do
+      handle_existing_authorization(conn, params)
+    else
+      _ -> do_authorize(conn, params)
+    end
+  end
+
   def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params)
 
   defp do_authorize(%Plug.Conn{} = conn, params) do
index 6958519de05d95acfadeecd749af7a52ec82d778..12a7c2365446e8ba67754b3de17af8a25bc14cd7 100644 (file)
@@ -8,6 +8,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do
   alias Fallback.RedirectController
   alias Pleroma.Activity
   alias Pleroma.Object
+  alias Pleroma.Plugs.RateLimiter
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPubController
   alias Pleroma.Web.ActivityPub.ObjectView
@@ -17,8 +18,8 @@ defmodule Pleroma.Web.OStatus.OStatusController do
   alias Pleroma.Web.Router
 
   plug(
-    Pleroma.Plugs.RateLimiter,
-    {:ap_routes, params: ["uuid"]} when action in [:object, :activity]
+    RateLimiter,
+    [name: :ap_routes, params: ["uuid"]] when action in [:object, :activity]
   )
 
   plug(
index db6faac835d2c4166e362cb1289db91127b59b98..bc2f1017c83fb52f9a3b5e1e025f835d9c2fcc99 100644 (file)
@@ -42,7 +42,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do
     when action != :confirmation_resend
   )
 
-  plug(RateLimiter, :account_confirmation_resend when action == :confirmation_resend)
+  plug(RateLimiter, [name: :account_confirmation_resend] when action == :confirmation_resend)
   plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe])
   plug(:put_view, Pleroma.Web.MastodonAPI.AccountView)
 
index 651a9942382355871610cc687cb7dec48ab20199..8fed3f5bb7dfcc07e21164ab7093a89984fdfc7e 100644 (file)
@@ -7,10 +7,15 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do
 
   import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
 
+  alias Pleroma.Activity
   alias Pleroma.Conversation.Participation
   alias Pleroma.Notification
+  alias Pleroma.Object
   alias Pleroma.Plugs.OAuthScopesPlug
+  alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.CommonAPI
+  alias Pleroma.Web.MastodonAPI.AccountView
   alias Pleroma.Web.MastodonAPI.ConversationView
   alias Pleroma.Web.MastodonAPI.NotificationView
   alias Pleroma.Web.MastodonAPI.StatusView
@@ -29,6 +34,47 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do
 
   plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
 
+  def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do
+    with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id),
+         %Object{data: %{"reactions" => emoji_reactions}} <- Object.normalize(activity) do
+      reactions =
+        emoji_reactions
+        |> Enum.map(fn {emoji, users} ->
+          users = Enum.map(users, &User.get_cached_by_ap_id/1)
+          {emoji, AccountView.render("index.json", %{users: users, for: user, as: :user})}
+        end)
+        |> Enum.into(%{})
+
+      conn
+      |> json(reactions)
+    else
+      _e ->
+        conn
+        |> json(%{})
+    end
+  end
+
+  def react_with_emoji(%{assigns: %{user: user}} = conn, %{"id" => activity_id, "emoji" => emoji}) do
+    with {:ok, _activity, _object} <- CommonAPI.react_with_emoji(activity_id, user, emoji),
+         activity <- Activity.get_by_id(activity_id) do
+      conn
+      |> put_view(StatusView)
+      |> render("show.json", %{activity: activity, for: user, as: :activity})
+    end
+  end
+
+  def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{
+        "id" => activity_id,
+        "emoji" => emoji
+      }) do
+    with {:ok, _activity, _object} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji),
+         activity <- Activity.get_by_id(activity_id) do
+      conn
+      |> put_view(StatusView)
+      |> render("show.json", %{activity: activity, for: user, as: :activity})
+    end
+  end
+
   def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do
     with %Participation{} = participation <- Participation.get(participation_id),
          true <- user.id == participation.user_id do
index af7c0e2898d4a7bd8bed9e4944858c299ae760e8..129da422c2b3a4a13b9aee773859f2491910fe39 100644 (file)
@@ -179,8 +179,9 @@ defmodule Pleroma.Web.Router do
     get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses)
 
     get("/reports", AdminAPIController, :list_reports)
+    get("/grouped_reports", AdminAPIController, :list_grouped_reports)
     get("/reports/:id", AdminAPIController, :report_show)
-    put("/reports/:id", AdminAPIController, :report_update_state)
+    patch("/reports", AdminAPIController, :reports_update)
     post("/reports/:id/respond", AdminAPIController, :report_respond)
 
     put("/statuses/:id", AdminAPIController, :status_update)
@@ -261,6 +262,12 @@ defmodule Pleroma.Web.Router do
     end
   end
 
+  scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
+    pipe_through(:api)
+
+    get("/statuses/:id/emoji_reactions_by", PleromaAPIController, :emoji_reactions_by)
+  end
+
   scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
     scope [] do
       pipe_through(:authenticated_api)
@@ -274,6 +281,8 @@ defmodule Pleroma.Web.Router do
       pipe_through(:authenticated_api)
 
       patch("/conversations/:id", PleromaAPIController, :update_conversation)
+      post("/statuses/:id/react_with_emoji", PleromaAPIController, :react_with_emoji)
+      post("/statuses/:id/unreact_with_emoji", PleromaAPIController, :unreact_with_emoji)
       post("/notifications/read", PleromaAPIController, :read_notification)
 
       patch("/accounts/update_avatar", AccountController, :update_avatar)
@@ -496,6 +505,7 @@ defmodule Pleroma.Web.Router do
 
   pipeline :ostatus do
     plug(:accepts, ["html", "xml", "atom", "activity+json", "json"])
+    plug(Pleroma.Plugs.StaticFEPlug)
   end
 
   pipeline :oembed do
diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex
new file mode 100644 (file)
index 0000000..8ccf15f
--- /dev/null
@@ -0,0 +1,163 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.StaticFE.StaticFEController do
+  use Pleroma.Web, :controller
+
+  alias Pleroma.Activity
+  alias Pleroma.Object
+  alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.ActivityPub
+  alias Pleroma.Web.ActivityPub.Visibility
+  alias Pleroma.Web.Metadata
+  alias Pleroma.Web.Router.Helpers
+
+  plug(:put_layout, :static_fe)
+  plug(:put_view, Pleroma.Web.StaticFE.StaticFEView)
+  plug(:assign_id)
+
+  @page_keys ["max_id", "min_id", "limit", "since_id", "order"]
+
+  defp get_title(%Object{data: %{"name" => name}}) when is_binary(name),
+    do: name
+
+  defp get_title(%Object{data: %{"summary" => summary}}) when is_binary(summary),
+    do: summary
+
+  defp get_title(_), do: nil
+
+  defp not_found(conn, message) do
+    conn
+    |> put_status(404)
+    |> render("error.html", %{message: message, meta: ""})
+  end
+
+  def get_counts(%Activity{} = activity) do
+    %Object{data: data} = Object.normalize(activity)
+
+    %{
+      likes: data["like_count"] || 0,
+      replies: data["repliesCount"] || 0,
+      announces: data["announcement_count"] || 0
+    }
+  end
+
+  def represent(%Activity{} = activity), do: represent(activity, false)
+
+  def represent(%Activity{object: %Object{data: data}} = activity, selected) do
+    {:ok, user} = User.get_or_fetch(activity.object.data["actor"])
+
+    link =
+      case user.local do
+        true -> Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
+        _ -> data["url"] || data["external_url"] || data["id"]
+      end
+
+    %{
+      user: user,
+      title: get_title(activity.object),
+      content: data["content"] || nil,
+      attachment: data["attachment"],
+      link: link,
+      published: data["published"],
+      sensitive: data["sensitive"],
+      selected: selected,
+      counts: get_counts(activity),
+      id: activity.id
+    }
+  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})
+
+        timeline =
+          ActivityPub.fetch_user_activities(user, nil, Map.take(params, @page_keys))
+          |> 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,
+          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
+
+  def assign_id(%{path_info: ["notice", notice_id]} = conn, _opts),
+    do: assign(conn, :notice_id, notice_id)
+
+  def assign_id(%{path_info: ["users", user_id]} = conn, _opts),
+    do: assign(conn, :username_or_id, user_id)
+
+  def assign_id(%{path_info: ["objects", object_id]} = conn, _opts),
+    do: assign(conn, :object_id, object_id)
+
+  def assign_id(%{path_info: ["activities", activity_id]} = conn, _opts),
+    do: assign(conn, :activity_id, activity_id)
+
+  def assign_id(conn, _opts), do: conn
+end
diff --git a/lib/pleroma/web/static_fe/static_fe_view.ex b/lib/pleroma/web/static_fe/static_fe_view.ex
new file mode 100644 (file)
index 0000000..821ece9
--- /dev/null
@@ -0,0 +1,47 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.StaticFE.StaticFEView do
+  use Pleroma.Web, :view
+
+  alias Calendar.Strftime
+  alias Pleroma.Emoji.Formatter
+  alias Pleroma.User
+  alias Pleroma.Web.Endpoint
+  alias Pleroma.Web.Gettext
+  alias Pleroma.Web.MediaProxy
+  alias Pleroma.Web.Metadata.Utils
+  alias Pleroma.Web.Router.Helpers
+
+  use Phoenix.HTML
+
+  @media_types ["image", "audio", "video"]
+
+  def emoji_for_user(%User{} = user) do
+    user.source_data
+    |> Map.get("tag", [])
+    |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end)
+    |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} ->
+      {String.trim(name, ":"), url}
+    end)
+  end
+
+  def fetch_media_type(%{"mediaType" => mediaType}) do
+    Utils.fetch_media_type(@media_types, mediaType)
+  end
+
+  def format_date(date) do
+    {:ok, date, _} = DateTime.from_iso8601(date)
+    Strftime.strftime!(date, "%Y/%m/%d %l:%M:%S %p UTC")
+  end
+
+  def instance_name, do: Pleroma.Config.get([:instance, :name], "Pleroma")
+
+  def open_content? do
+    Pleroma.Config.get(
+      [:frontend_configurations, :collapse_message_with_subjects],
+      true
+    )
+  end
+end
diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex
new file mode 100644 (file)
index 0000000..819632c
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width,initial-scale=1,minimal-ui" />
+    <title><%= Pleroma.Config.get([:instance, :name]) %></title>
+    <%= Phoenix.HTML.raw(assigns[:meta] || "") %>
+    <link rel="stylesheet" href="/static/static-fe.css">
+  </head>
+  <body>
+    <div class="container">
+      <%= render @view_module, @view_template, assigns %>
+    </div>
+  </body>
+</html>
diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex
new file mode 100644 (file)
index 0000000..7e04e95
--- /dev/null
@@ -0,0 +1,8 @@
+<%= case @mediaType do %>
+<% "audio" -> %>
+<audio src="<%= @url %>" controls="controls"></audio>
+<% "video" -> %>
+<video src="<%= @url %>" controls="controls"></video>
+<% _ -> %>
+<img src="<%= @url %>" alt="<%= @name %>" title="<%= @name %>">
+<% end %>
diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex
new file mode 100644 (file)
index 0000000..df5e5ee
--- /dev/null
@@ -0,0 +1,37 @@
+<div class="activity" <%= if @selected do %> id="selected" <% end %>>
+  <p class="pull-right">
+    <%= link format_date(@published), to: @link, class: "activity-link" %>
+  </p>
+  <%= render("_user_card.html", %{user: @user}) %>
+  <div class="activity-content">
+    <%= if @title != "" do %>
+      <details <%= if open_content?() do %>open<% end %>>
+        <summary><%= raw @title %></summary>
+        <div class="e-content"><%= raw @content %></div>
+      </details>
+    <% else %>
+      <div class="e-content"><%= raw @content %></div>
+    <% end %>
+    <%= for %{"name" => name, "url" => [url | _]} <- @attachment do %>
+      <%= if @sensitive do %>
+        <details class="nsfw">
+          <summary><%= Gettext.gettext("sensitive media") %></summary>
+          <div>
+            <%= render("_attachment.html", %{name: name, url: url["href"],
+                                             mediaType: fetch_media_type(url)}) %>
+          </div>
+        </details>
+      <% else %>
+        <%= render("_attachment.html", %{name: name, url: url["href"],
+                                         mediaType: fetch_media_type(url)}) %>
+      <% end %>
+    <% end %>
+  </div>
+  <%= if @selected do %>
+    <dl class="counts">
+      <dt><%= Gettext.gettext("replies") %></dt><dd><%= @counts.replies %></dd>
+      <dt><%= Gettext.gettext("announces") %></dt><dd><%= @counts.announces %></dd>
+      <dt><%= Gettext.gettext("likes") %></dt><dd><%= @counts.likes %></dd>
+    </dl>
+  <% end %>
+</div>
diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_user_card.html.eex
new file mode 100644 (file)
index 0000000..c7789f9
--- /dev/null
@@ -0,0 +1,11 @@
+<div class="p-author h-card">
+  <a class="u-url" rel="author noopener" href="<%= User.profile_url(@user) %>">
+    <div class="avatar">
+      <img src="<%= User.avatar_url(@user) |> MediaProxy.url %>" width="48" height="48" alt="">
+    </div>
+    <span class="display-name">
+      <bdi><%= raw (@user.name |> Formatter.emojify(emoji_for_user(@user))) %></bdi>
+      <span class="nickname"><%= @user.nickname %></span>
+    </span>
+  </a>
+</div>
diff --git a/lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/conversation.html.eex
new file mode 100644 (file)
index 0000000..2acd848
--- /dev/null
@@ -0,0 +1,11 @@
+<header>
+  <h1><%= link instance_name(), to: "/" %></h1>
+</header>
+
+<main>
+  <div class="conversation">
+    <%= for activity <- @activities do %>
+      <%= render("_notice.html", activity) %>
+    <% end %>
+  </div>
+</main>
diff --git a/lib/pleroma/web/templates/static_fe/static_fe/error.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/error.html.eex
new file mode 100644 (file)
index 0000000..d98a1eb
--- /dev/null
@@ -0,0 +1,7 @@
+<header>
+  <h1><%= gettext("Oops") %></h1>
+</header>
+
+<main>
+  <p><%= @message %></p>
+</main>
diff --git a/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/profile.html.eex
new file mode 100644 (file)
index 0000000..94063c9
--- /dev/null
@@ -0,0 +1,31 @@
+<header>
+  <h1><%= link instance_name(), to: "/" %></h1>
+
+  <h3>
+    <form class="pull-right collapse" method="POST" action="<%= Helpers.util_path(@conn, :remote_subscribe) %>">
+      <input type="hidden" name="nickname" value="<%= @user.nickname %>">
+      <input type="hidden" name="profile" value="">
+      <button type="submit" class="collapse">Remote follow</button>
+    </form>
+    <%= raw Formatter.emojify(@user.name, emoji_for_user(@user)) %> |
+    <%= link "@#{@user.nickname}@#{Endpoint.host()}", to: User.profile_url(@user) %>
+  </h3>
+  <p><%= raw @user.bio %></p>
+</header>
+
+<main>
+  <div class="activity-stream">
+    <%= for activity <- @timeline do %>
+      <%= render("_notice.html", Map.put(activity, :selected, false)) %>
+    <% end %>
+    <p id="pagination">
+      <%= if @prev_page_id do %>
+        <%= link "«", to: "?min_id=" <> @prev_page_id %>
+      <% end %>
+      <%= if @prev_page_id && @next_page_id, do: " | " %>
+      <%= if @next_page_id do %>
+        <%= link "»", to: "?max_id=" <> @next_page_id %>
+      <% end %>
+    </p>
+  </div>
+</main>
diff --git a/mix.exs b/mix.exs
index dd7c7e979b3e03fa4376fb440b81b9437d808482..81ce4f25cef74d619776b7a7b6b18a569ff014f8 100644 (file)
--- a/mix.exs
+++ b/mix.exs
@@ -155,7 +155,6 @@ defmodule Pleroma.Mixfile do
       {:joken, "~> 2.0"},
       {:benchee, "~> 1.0"},
       {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)},
-      {:ex_rated, "~> 1.3"},
       {:ex_const, "~> 0.2"},
       {:plug_static_index_html, "~> 1.0.0"},
       {:excoveralls, "~> 0.11.1", only: :test},
index 5b471fe3dbfdb9f1d17807269224da2c35ec3b85..d4a80df77ccce408db16e0806333bfb24258bb6f 100644 (file)
--- a/mix.lock
+++ b/mix.lock
@@ -33,7 +33,6 @@
   "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm"},
   "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
   "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm"},
-  "ex_rated": {:hex, :ex_rated, "1.3.3", "30ecbdabe91f7eaa9d37fa4e81c85ba420f371babeb9d1910adbcd79ec798d27", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm"},
   "ex_syslogger": {:git, "https://github.com/slashmili/ex_syslogger.git", "f3963399047af17e038897c69e20d552e6899e1d", [tag: "1.4.0"]},
   "excoveralls": {:hex, :excoveralls, "0.11.2", "0c6f2c8db7683b0caa9d490fb8125709c54580b4255ffa7ad35f3264b075a643", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
   "fast_html": {:hex, :fast_html, "0.99.3", "e7ce6245fed0635f4719a31cc409091ed17b2091165a4a1cffbf2ceac77abbf4", [:make, :mix], [], "hexpm"},
index c8e69cab5614b44fe0a3d3a7d1ee33af8e4e6a2c..8bae42f6da9e4e80a877a58b864fc2131e663d7e 100644 (file)
@@ -28,7 +28,8 @@
             "oauthRegistrationEndpoint": {
                 "@id": "litepub:oauthRegistrationEndpoint",
                 "@type": "@id"
-            }
+            },
+            "EmojiReaction": "litepub:EmojiReaction"
         }
     ]
 }
diff --git a/priv/static/static/static-fe.css b/priv/static/static/static-fe.css
new file mode 100644 (file)
index 0000000..19c5638
--- /dev/null
@@ -0,0 +1,176 @@
+body {
+    background-color: #282c37;
+    font-family: sans-serif;
+    color: white;
+}
+
+main {
+    margin: 50px auto;
+    max-width: 960px;
+    padding: 40px;
+    background-color: #313543;
+    border-radius: 4px;
+}
+
+header {
+    margin: 50px auto;
+    max-width: 960px;
+    padding: 40px;
+    background-color: #313543;
+    border-radius: 4px;
+}
+
+.activity {
+    border-radius: 4px;
+    padding: 1em;
+    padding-bottom: 2em;
+    margin-bottom: 1em;
+}
+
+.avatar {
+    cursor: pointer;
+}
+
+.avatar img {
+    float: left;
+    border-radius: 4px;
+    margin-right: 4px;
+}
+
+.activity-content img, video, audio {
+    padding: 1em;
+    max-width: 800px;
+    max-height: 800px;
+}
+
+#selected {
+    background-color: #1b2735;
+}
+
+.counts dt, .counts dd {
+    float: left;
+    margin-left: 1em;
+}
+
+a {
+    color: white;
+}
+
+.h-card {
+    min-height: 48px;
+    margin-bottom: 8px;
+}
+
+header a, .h-card a {
+    text-decoration: none;
+}
+
+header a:hover, .h-card a:hover {
+    text-decoration: underline;
+}
+
+.display-name {
+    padding-top: 4px;
+    display: block;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    color: white;
+}
+
+/* keep emoji from being hilariously huge */
+.display-name img {
+    max-height: 1em;
+}
+
+.display-name .nickname {
+    padding-top: 4px;
+    display: block;
+}
+
+.nickname:hover {
+    text-decoration: none;
+}
+
+.pull-right {
+    float: right;
+}
+
+.collapse {
+    margin: 0;
+    width: auto;
+}
+
+h1 {
+    margin: 0;
+}
+
+h2 {
+    color: #9baec8;
+    font-weight: normal;
+    font-size: 20px;
+    margin-bottom: 40px;
+}
+
+form {
+    width: 100%;
+}
+
+input {
+    box-sizing: border-box;
+    width: 100%;
+    padding: 10px;
+    margin-top: 20px;
+    background-color: rgba(0,0,0,.1);
+    color: white;
+    border: 0;
+    border-bottom: 2px solid #9baec8;
+    font-size: 14px;
+}
+
+input:focus {
+    border-bottom: 2px solid #4b8ed8;
+}
+
+input[type="checkbox"] {
+    width: auto;
+}
+
+button {
+    box-sizing: border-box;
+    width: 100%;
+    color: white;
+    background-color: #419bdd;
+    border-radius: 4px;
+    border: none;
+    padding: 10px;
+    margin-top: 30px;
+    text-transform: uppercase;
+    font-weight: 500;
+    font-size: 16px;
+}
+
+.alert-danger {
+    box-sizing: border-box;
+    width: 100%;
+    color: #D8000C;
+    background-color: #FFD2D2;
+    border-radius: 4px;
+    border: none;
+    padding: 10px;
+    margin-top: 20px;
+    font-weight: 500;
+    font-size: 16px;
+}
+
+.alert-info {
+    box-sizing: border-box;
+    width: 100%;
+    color: #00529B;
+    background-color: #BDE5F8;
+    border-radius: 4px;
+    border: none;
+    padding: 10px;
+    margin-top: 20px;
+    font-weight: 500;
+    font-size: 16px;
+}
index 1fdbd0fdfd7d57880cf9992bf340975a8f7dabd3..7bdf2b6fae9974e77c931ca284ad3fd44a06048e 100644 (file)
@@ -6,6 +6,14 @@ defmodule Pleroma.EmojiTest do
   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?("☂")
+      assert Emoji.is_unicode_emoji?("🥺")
+    end
+  end
+
   describe "get_all/0" do
     setup do
       emoji_list = Emoji.get_all()
diff --git a/test/fixtures/emoji-reaction.json b/test/fixtures/emoji-reaction.json
new file mode 100644 (file)
index 0000000..3812e43
--- /dev/null
@@ -0,0 +1,30 @@
+{
+  "type": "EmojiReaction",
+  "signature": {
+    "type": "RsaSignature2017",
+    "signatureValue": "fdxMfQSMwbC6wP6sh6neS/vM5879K67yQkHTbiT5Npr5wAac0y6+o3Ij+41tN3rL6wfuGTosSBTHOtta6R4GCOOhCaCSLMZKypnp1VltCzLDoyrZELnYQIC8gpUXVmIycZbREk22qWUe/w7DAFaKK4UscBlHDzeDVcA0K3Se5Sluqi9/Zh+ldAnEzj/rSEPDjrtvf5wGNf3fHxbKSRKFt90JvKK6hS+vxKUhlRFDf6/SMETw+EhwJSNW4d10yMUakqUWsFv4Acq5LW7l+HpYMvlYY1FZhNde1+uonnCyuQDyvzkff8zwtEJmAXC4RivO/VVLa17SmqheJZfI8oluVg==",
+    "creator": "http://mastodon.example.org/users/admin#main-key",
+    "created": "2018-02-17T18:57:49Z"
+  },
+  "object": "http://localtesting.pleroma.lol/objects/eb92579d-3417-42a8-8652-2492c2d4f454",
+  "content": "👌",
+  "nickname": "lain",
+  "id": "http://mastodon.example.org/users/admin#reactions/2",
+  "actor": "http://mastodon.example.org/users/admin",
+  "@context": [
+    "https://www.w3.org/ns/activitystreams",
+    "https://w3id.org/security/v1",
+    {
+      "toot": "http://joinmastodon.org/ns#",
+      "sensitive": "as:sensitive",
+      "ostatus": "http://ostatus.org#",
+      "movedTo": "as:movedTo",
+      "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+      "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+      "conversation": "ostatus:conversation",
+      "atomUri": "ostatus:atomUri",
+      "Hashtag": "as:Hashtag",
+      "Emoji": "toot:Emoji"
+    }
+  ]
+}
diff --git a/test/fixtures/misskey-like.json b/test/fixtures/misskey-like.json
new file mode 100644 (file)
index 0000000..84d56f4
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "@context" : [
+    "https://www.w3.org/ns/activitystreams",
+    "https://w3id.org/security/v1",
+    {"Hashtag" : "as:Hashtag"}
+  ],
+  "_misskey_reaction" : "pudding",
+  "actor": "http://mastodon.example.org/users/admin",
+  "cc" : ["https://testing.pleroma.lol/users/lain"],
+  "id" : "https://misskey.xyz/75149198-2f45-46e4-930a-8b0538297075",
+  "nickname" : "lain",
+  "object" : "https://testing.pleroma.lol/objects/c331bbf7-2eb9-4801-a709-2a6103492a5a",
+  "type" : "Like"
+}
index 71fe5204cbf634ce2ee96c04147df5fe5d99d900..7636803a63a97f6f313d91798d40f3ae39874770 100644 (file)
@@ -17,6 +17,16 @@ defmodule Pleroma.Object.ContainmentTest do
   end
 
   describe "general origin containment" do
+    test "works for completely actorless posts" do
+      assert :error ==
+               Containment.contain_origin("https://glaceon.social/users/monorail", %{
+                 "deleted" => "2019-10-30T05:48:50.249606Z",
+                 "formerType" => "Note",
+                 "id" => "https://glaceon.social/users/monorail/statuses/103049757364029187",
+                 "type" => "Tombstone"
+               })
+    end
+
     test "contain_origin_from_id() catches obvious spoofing attempts" do
       data = %{
         "id" => "http://example.com/~alyssa/activities/1234.json"
index 395095079f1346b377af6e251a2ebab54cb3fdea..49f63c424aa08079479646bdff8ae5b0a7802f2c 100644 (file)
@@ -12,163 +12,196 @@ defmodule Pleroma.Plugs.RateLimiterTest do
 
   # Note: each example must work with separate buckets in order to prevent concurrency issues
 
-  test "init/1" do
-    limiter_name = :test_init
-    Pleroma.Config.put([:rate_limit, limiter_name], {1, 1})
+  describe "config" do
+    test "config is required for plug to work" do
+      limiter_name = :test_init
+      Pleroma.Config.put([:rate_limit, limiter_name], {1, 1})
 
-    assert {limiter_name, {1, 1}, []} == RateLimiter.init(limiter_name)
-    assert nil == RateLimiter.init(:foo)
-  end
+      assert %{limits: {1, 1}, name: :test_init, opts: [name: :test_init]} ==
+               RateLimiter.init(name: limiter_name)
 
-  test "ip/1" do
-    assert "127.0.0.1" == RateLimiter.ip(%{remote_ip: {127, 0, 0, 1}})
-  end
+      assert nil == RateLimiter.init(name: :foo)
+    end
 
-  test "it restricts by opts" do
-    limiter_name = :test_opts
-    scale = 1000
-    limit = 5
+    test "it restricts based on config values" do
+      limiter_name = :test_opts
+      scale = 80
+      limit = 5
 
-    Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
+      Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
 
-    opts = RateLimiter.init(limiter_name)
-    conn = conn(:get, "/")
-    bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
+      opts = RateLimiter.init(name: limiter_name)
+      conn = conn(:get, "/")
 
-    conn = RateLimiter.call(conn, opts)
-    assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      for i <- 1..5 do
+        conn = RateLimiter.call(conn, opts)
+        assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, opts)
+        Process.sleep(10)
+      end
 
-    conn = RateLimiter.call(conn, opts)
-    assert {2, 3, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      conn = RateLimiter.call(conn, opts)
+      assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
+      assert conn.halted
 
-    conn = RateLimiter.call(conn, opts)
-    assert {3, 2, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      Process.sleep(50)
 
-    conn = RateLimiter.call(conn, opts)
-    assert {4, 1, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      conn = conn(:get, "/")
 
-    conn = RateLimiter.call(conn, opts)
-    assert {5, 0, to_reset, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      conn = RateLimiter.call(conn, opts)
+      assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, opts)
 
-    conn = RateLimiter.call(conn, opts)
+      refute conn.status == Plug.Conn.Status.code(:too_many_requests)
+      refute conn.resp_body
+      refute conn.halted
+    end
+  end
 
-    assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
-    assert conn.halted
+  describe "options" do
+    test "`bucket_name` option overrides default bucket name" do
+      limiter_name = :test_bucket_name
 
-    Process.sleep(to_reset)
+      Pleroma.Config.put([:rate_limit, limiter_name], {1000, 5})
 
-    conn = conn(:get, "/")
+      base_bucket_name = "#{limiter_name}:group1"
+      opts = RateLimiter.init(name: limiter_name, bucket_name: base_bucket_name)
 
-    conn = RateLimiter.call(conn, opts)
-    assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      conn = conn(:get, "/")
 
-    refute conn.status == Plug.Conn.Status.code(:too_many_requests)
-    refute conn.resp_body
-    refute conn.halted
-  end
+      RateLimiter.call(conn, opts)
+      assert {1, 4} = RateLimiter.inspect_bucket(conn, base_bucket_name, opts)
+      assert {:err, :not_found} = RateLimiter.inspect_bucket(conn, limiter_name, opts)
+    end
 
-  test "`bucket_name` option overrides default bucket name" do
-    limiter_name = :test_bucket_name
-    scale = 1000
-    limit = 5
+    test "`params` option allows different queries to be tracked independently" do
+      limiter_name = :test_params
+      Pleroma.Config.put([:rate_limit, limiter_name], {1000, 5})
 
-    Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
-    base_bucket_name = "#{limiter_name}:group1"
-    opts = RateLimiter.init({limiter_name, bucket_name: base_bucket_name})
+      opts = RateLimiter.init(name: limiter_name, params: ["id"])
 
-    conn = conn(:get, "/")
-    default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
-    customized_bucket_name = "#{base_bucket_name}:#{RateLimiter.ip(conn)}"
+      conn = conn(:get, "/?id=1")
+      conn = Plug.Conn.fetch_query_params(conn)
+      conn_2 = conn(:get, "/?id=2")
 
-    RateLimiter.call(conn, opts)
-    assert {1, 4, _, _, _} = ExRated.inspect_bucket(customized_bucket_name, scale, limit)
-    assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
-  end
-
-  test "`params` option appends specified params' values to bucket name" do
-    limiter_name = :test_params
-    scale = 1000
-    limit = 5
+      RateLimiter.call(conn, opts)
+      assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, opts)
+      assert {0, 5} = RateLimiter.inspect_bucket(conn_2, limiter_name, opts)
+    end
 
-    Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
-    opts = RateLimiter.init({limiter_name, params: ["id"]})
-    id = "1"
+    test "it supports combination of options modifying bucket name" do
+      limiter_name = :test_options_combo
+      Pleroma.Config.put([:rate_limit, limiter_name], {1000, 5})
 
-    conn = conn(:get, "/?id=#{id}")
-    conn = Plug.Conn.fetch_query_params(conn)
+      base_bucket_name = "#{limiter_name}:group1"
+      opts = RateLimiter.init(name: limiter_name, bucket_name: base_bucket_name, params: ["id"])
+      id = "100"
 
-    default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
-    parametrized_bucket_name = "#{limiter_name}:#{id}:#{RateLimiter.ip(conn)}"
+      conn = conn(:get, "/?id=#{id}")
+      conn = Plug.Conn.fetch_query_params(conn)
+      conn_2 = conn(:get, "/?id=#{101}")
 
-    RateLimiter.call(conn, opts)
-    assert {1, 4, _, _, _} = ExRated.inspect_bucket(parametrized_bucket_name, scale, limit)
-    assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
+      RateLimiter.call(conn, opts)
+      assert {1, 4} = RateLimiter.inspect_bucket(conn, base_bucket_name, opts)
+      assert {0, 5} = RateLimiter.inspect_bucket(conn_2, base_bucket_name, opts)
+    end
   end
 
-  test "it supports combination of options modifying bucket name" do
-    limiter_name = :test_options_combo
-    scale = 1000
-    limit = 5
+  describe "unauthenticated users" do
+    test "are restricted based on remote IP" do
+      limiter_name = :test_unauthenticated
+      Pleroma.Config.put([:rate_limit, limiter_name], [{1000, 5}, {1, 10}])
+
+      opts = RateLimiter.init(name: limiter_name)
 
-    Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit})
-    base_bucket_name = "#{limiter_name}:group1"
-    opts = RateLimiter.init({limiter_name, bucket_name: base_bucket_name, params: ["id"]})
-    id = "100"
+      conn = %{conn(:get, "/") | remote_ip: {127, 0, 0, 2}}
+      conn_2 = %{conn(:get, "/") | remote_ip: {127, 0, 0, 3}}
 
-    conn = conn(:get, "/?id=#{id}")
-    conn = Plug.Conn.fetch_query_params(conn)
+      for i <- 1..5 do
+        conn = RateLimiter.call(conn, opts)
+        assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, opts)
+        refute conn.halted
+      end
 
-    default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}"
-    parametrized_bucket_name = "#{base_bucket_name}:#{id}:#{RateLimiter.ip(conn)}"
+      conn = RateLimiter.call(conn, opts)
 
-    RateLimiter.call(conn, opts)
-    assert {1, 4, _, _, _} = ExRated.inspect_bucket(parametrized_bucket_name, scale, limit)
-    assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit)
+      assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
+      assert conn.halted
+
+      conn_2 = RateLimiter.call(conn_2, opts)
+      assert {1, 4} = RateLimiter.inspect_bucket(conn_2, limiter_name, opts)
+
+      refute conn_2.status == Plug.Conn.Status.code(:too_many_requests)
+      refute conn_2.resp_body
+      refute conn_2.halted
+    end
   end
 
-  test "optional limits for authenticated users" do
-    limiter_name = :test_authenticated
-    Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo)
+  describe "authenticated users" do
+    setup do
+      Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo)
+
+      :ok
+    end
+
+    test "can have limits seperate from unauthenticated connections" do
+      limiter_name = :test_authenticated
+
+      scale = 1000
+      limit = 5
+      Pleroma.Config.put([:rate_limit, limiter_name], [{1, 10}, {scale, limit}])
+
+      opts = RateLimiter.init(name: limiter_name)
 
-    scale = 1000
-    limit = 5
-    Pleroma.Config.put([:rate_limit, limiter_name], [{1, 10}, {scale, limit}])
+      user = insert(:user)
+      conn = conn(:get, "/") |> assign(:user, user)
 
-    opts = RateLimiter.init(limiter_name)
+      for i <- 1..5 do
+        conn = RateLimiter.call(conn, opts)
+        assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, opts)
+        refute conn.halted
+      end
 
-    user = insert(:user)
-    conn = conn(:get, "/") |> assign(:user, user)
-    bucket_name = "#{limiter_name}:#{user.id}"
+      conn = RateLimiter.call(conn, opts)
 
-    conn = RateLimiter.call(conn, opts)
-    assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
+      assert conn.halted
 
-    conn = RateLimiter.call(conn, opts)
-    assert {2, 3, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      Process.sleep(1550)
 
-    conn = RateLimiter.call(conn, opts)
-    assert {3, 2, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      conn = conn(:get, "/") |> assign(:user, user)
+      conn = RateLimiter.call(conn, opts)
+      assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, opts)
 
-    conn = RateLimiter.call(conn, opts)
-    assert {4, 1, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      refute conn.status == Plug.Conn.Status.code(:too_many_requests)
+      refute conn.resp_body
+      refute conn.halted
+    end
 
-    conn = RateLimiter.call(conn, opts)
-    assert {5, 0, to_reset, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+    test "diffrerent users are counted independently" do
+      limiter_name = :test_authenticated
+      Pleroma.Config.put([:rate_limit, limiter_name], [{1, 10}, {1000, 5}])
 
-    conn = RateLimiter.call(conn, opts)
+      opts = RateLimiter.init(name: limiter_name)
 
-    assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
-    assert conn.halted
+      user = insert(:user)
+      conn = conn(:get, "/") |> assign(:user, user)
 
-    Process.sleep(to_reset)
+      user_2 = insert(:user)
+      conn_2 = conn(:get, "/") |> assign(:user, user_2)
 
-    conn = conn(:get, "/") |> assign(:user, user)
+      for i <- 1..5 do
+        conn = RateLimiter.call(conn, opts)
+        assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, opts)
+      end
 
-    conn = RateLimiter.call(conn, opts)
-    assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit)
+      conn = RateLimiter.call(conn, opts)
+      assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests)
+      assert conn.halted
 
-    refute conn.status == Plug.Conn.Status.code(:too_many_requests)
-    refute conn.resp_body
-    refute conn.halted
+      conn_2 = RateLimiter.call(conn_2, opts)
+      assert {1, 4} = RateLimiter.inspect_bucket(conn_2, limiter_name, opts)
+      refute conn_2.status == Plug.Conn.Status.code(:too_many_requests)
+      refute conn_2.resp_body
+      refute conn_2.halted
+    end
   end
 end
index 0d0281fafeeb4ee333f5b9b42ed2878bc5c360f3..d437ad456c78a8d1c85e739748fbd321605d6021 100644 (file)
@@ -812,6 +812,78 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
     end
   end
 
+  describe "react to an object" do
+    test_with_mock "sends an activity to federation", Pleroma.Web.Federator, [:passthrough], [] do
+      Pleroma.Config.put([:instance, :federating], true)
+      user = insert(:user)
+      reactor = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"})
+      assert object = Object.normalize(activity)
+
+      {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥")
+
+      assert called(Pleroma.Web.Federator.publish(reaction_activity))
+    end
+
+    test "adds an emoji reaction activity to the db" do
+      user = insert(:user)
+      reactor = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"})
+      assert object = Object.normalize(activity)
+
+      {:ok, reaction_activity, object} = ActivityPub.react_with_emoji(reactor, object, "🔥")
+
+      assert reaction_activity
+
+      assert reaction_activity.data["actor"] == reactor.ap_id
+      assert reaction_activity.data["type"] == "EmojiReaction"
+      assert reaction_activity.data["content"] == "🔥"
+      assert reaction_activity.data["object"] == object.data["id"]
+      assert reaction_activity.data["to"] == [User.ap_followers(reactor), activity.data["actor"]]
+      assert reaction_activity.data["context"] == object.data["context"]
+      assert object.data["reaction_count"] == 1
+      assert object.data["reactions"]["🔥"] == [reactor.ap_id]
+    end
+  end
+
+  describe "unreacting to an object" do
+    test_with_mock "sends an activity to federation", Pleroma.Web.Federator, [:passthrough], [] do
+      Pleroma.Config.put([:instance, :federating], true)
+      user = insert(:user)
+      reactor = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"})
+      assert object = Object.normalize(activity)
+
+      {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥")
+
+      assert called(Pleroma.Web.Federator.publish(reaction_activity))
+
+      {:ok, unreaction_activity, _object} =
+        ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"])
+
+      assert called(Pleroma.Web.Federator.publish(unreaction_activity))
+    end
+
+    test "adds an undo activity to the db" do
+      user = insert(:user)
+      reactor = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"})
+      assert object = Object.normalize(activity)
+
+      {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥")
+
+      {:ok, unreaction_activity, _object} =
+        ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"])
+
+      assert unreaction_activity.actor == reactor.ap_id
+      assert unreaction_activity.data["object"] == reaction_activity.data["id"]
+
+      object = Object.get_by_ap_id(object.data["id"])
+      assert object.data["reaction_count"] == 0
+      assert object.data["reactions"] == %{}
+    end
+  end
+
   describe "like an object" do
     test_with_mock "sends an activity to federation", Pleroma.Web.Federator, [:passthrough], [] do
       Pleroma.Config.put([:instance, :federating], true)
diff --git a/test/web/activity_pub/mrf/object_age_policy_test.exs b/test/web/activity_pub/mrf/object_age_policy_test.exs
new file mode 100644 (file)
index 0000000..643609d
--- /dev/null
@@ -0,0 +1,105 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do
+  use Pleroma.DataCase
+  alias Pleroma.Config
+  alias Pleroma.User
+  alias Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy
+  alias Pleroma.Web.ActivityPub.Visibility
+
+  clear_config([:mrf_object_age]) do
+    Config.put(:mrf_object_age,
+      threshold: 172_800,
+      actions: [:delist, :strip_followers]
+    )
+  end
+
+  setup_all do
+    Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
+    :ok
+  end
+
+  describe "with reject action" do
+    test "it rejects an old post" do
+      Config.put([:mrf_object_age, :actions], [:reject])
+
+      data =
+        File.read!("test/fixtures/mastodon-post-activity.json")
+        |> Poison.decode!()
+
+      {:reject, _} = ObjectAgePolicy.filter(data)
+    end
+
+    test "it allows a new post" do
+      Config.put([:mrf_object_age, :actions], [:reject])
+
+      data =
+        File.read!("test/fixtures/mastodon-post-activity.json")
+        |> Poison.decode!()
+        |> Map.put("published", DateTime.utc_now() |> DateTime.to_iso8601())
+
+      {:ok, _} = ObjectAgePolicy.filter(data)
+    end
+  end
+
+  describe "with delist action" do
+    test "it delists an old post" do
+      Config.put([:mrf_object_age, :actions], [:delist])
+
+      data =
+        File.read!("test/fixtures/mastodon-post-activity.json")
+        |> Poison.decode!()
+
+      {:ok, _u} = User.get_or_fetch_by_ap_id(data["actor"])
+
+      {:ok, data} = ObjectAgePolicy.filter(data)
+
+      assert Visibility.get_visibility(%{data: data}) == "unlisted"
+    end
+
+    test "it allows a new post" do
+      Config.put([:mrf_object_age, :actions], [:delist])
+
+      data =
+        File.read!("test/fixtures/mastodon-post-activity.json")
+        |> Poison.decode!()
+        |> Map.put("published", DateTime.utc_now() |> DateTime.to_iso8601())
+
+      {:ok, _user} = User.get_or_fetch_by_ap_id(data["actor"])
+
+      {:ok, ^data} = ObjectAgePolicy.filter(data)
+    end
+  end
+
+  describe "with strip_followers action" do
+    test "it strips followers collections from an old post" do
+      Config.put([:mrf_object_age, :actions], [:strip_followers])
+
+      data =
+        File.read!("test/fixtures/mastodon-post-activity.json")
+        |> Poison.decode!()
+
+      {:ok, user} = User.get_or_fetch_by_ap_id(data["actor"])
+
+      {:ok, data} = ObjectAgePolicy.filter(data)
+
+      refute user.follower_address in data["to"]
+      refute user.follower_address in data["cc"]
+    end
+
+    test "it allows a new post" do
+      Config.put([:mrf_object_age, :actions], [:strip_followers])
+
+      data =
+        File.read!("test/fixtures/mastodon-post-activity.json")
+        |> Poison.decode!()
+        |> Map.put("published", DateTime.utc_now() |> DateTime.to_iso8601())
+
+      {:ok, _u} = User.get_or_fetch_by_ap_id(data["actor"])
+
+      {:ok, ^data} = ObjectAgePolicy.filter(data)
+    end
+  end
+end
index 4645eb39d9e1159dda48f0b07eb2d39cfcb57089..0bdd514e97384edf5a81c40bcf692185b2a1cbfd 100644 (file)
@@ -339,6 +339,80 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       assert data["object"] == activity.data["object"]
     end
 
+    test "it works for incoming misskey likes, turning them into EmojiReactions" do
+      user = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
+
+      data =
+        File.read!("test/fixtures/misskey-like.json")
+        |> Poison.decode!()
+        |> Map.put("object", activity.data["object"])
+
+      {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+      assert data["actor"] == data["actor"]
+      assert data["type"] == "EmojiReaction"
+      assert data["id"] == data["id"]
+      assert data["object"] == activity.data["object"]
+      assert data["content"] == "🍮"
+    end
+
+    test "it works for incoming misskey likes that contain unicode emojis, turning them into EmojiReactions" do
+      user = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
+
+      data =
+        File.read!("test/fixtures/misskey-like.json")
+        |> Poison.decode!()
+        |> Map.put("object", activity.data["object"])
+        |> Map.put("_misskey_reaction", "⭐")
+
+      {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+      assert data["actor"] == data["actor"]
+      assert data["type"] == "EmojiReaction"
+      assert data["id"] == data["id"]
+      assert data["object"] == activity.data["object"]
+      assert data["content"] == "⭐"
+    end
+
+    test "it works for incoming emoji reactions" do
+      user = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
+
+      data =
+        File.read!("test/fixtures/emoji-reaction.json")
+        |> Poison.decode!()
+        |> Map.put("object", activity.data["object"])
+
+      {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
+
+      assert data["actor"] == "http://mastodon.example.org/users/admin"
+      assert data["type"] == "EmojiReaction"
+      assert data["id"] == "http://mastodon.example.org/users/admin#reactions/2"
+      assert data["object"] == activity.data["object"]
+      assert data["content"] == "👌"
+    end
+
+    test "it works for incoming emoji reaction undos" do
+      user = insert(:user)
+
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"})
+      {:ok, reaction_activity, _object} = CommonAPI.react_with_emoji(activity.id, user, "👌")
+
+      data =
+        File.read!("test/fixtures/mastodon-undo-like.json")
+        |> Poison.decode!()
+        |> Map.put("object", reaction_activity.data["id"])
+        |> Map.put("actor", user.ap_id)
+
+      {:ok, activity} = Transmogrifier.handle_incoming(data)
+
+      assert activity.actor == user.ap_id
+      assert activity.data["id"] == data["id"]
+      assert activity.data["type"] == "Undo"
+    end
+
     test "it returns an error for incoming unlikes wihout a like activity" do
       user = insert(:user)
       {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"})
@@ -553,6 +627,20 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       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 update activities" do
       data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!()
 
index 586eb1d2f902bf11b83e9be7d2d2af942f68aa5d..1feb076ba953d9693aa03f38ee5ae0cf13d667f1 100644 (file)
@@ -636,4 +636,47 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
       assert updated_object.data["announcement_count"] == 1
     end
   end
+
+  describe "get_reports_grouped_by_status/1" do
+    setup do
+      [reporter, target_user] = insert_pair(:user)
+      first_status = insert(:note_activity, user: target_user)
+      second_status = insert(:note_activity, user: target_user)
+
+      CommonAPI.report(reporter, %{
+        "account_id" => target_user.id,
+        "comment" => "I feel offended",
+        "status_ids" => [first_status.id]
+      })
+
+      CommonAPI.report(reporter, %{
+        "account_id" => target_user.id,
+        "comment" => "I feel offended2",
+        "status_ids" => [second_status.id]
+      })
+
+      data = [%{activity: first_status.data["id"]}, %{activity: second_status.data["id"]}]
+
+      {:ok,
+       %{
+         first_status: first_status,
+         second_status: second_status,
+         data: data
+       }}
+    end
+
+    test "works for deprecated reports format", %{
+      first_status: first_status,
+      second_status: second_status,
+      data: data
+    } do
+      groups = Utils.get_reports_grouped_by_status(data).groups
+
+      first_group = Enum.find(groups, &(&1.status.id == first_status.data["id"]))
+      second_group = Enum.find(groups, &(&1.status.id == second_status.data["id"]))
+
+      assert first_group.status.id == first_status.data["id"]
+      assert second_group.status.id == second_status.data["id"]
+    end
+  end
 end
index 2a9e4f5a01783a774f20573015702cb2078d18b3..3a4c4d65c9e118bc9426798f7041354b4c2e8606 100644 (file)
@@ -1312,7 +1312,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
   end
 
-  describe "PUT /api/pleroma/admin/reports/:id" do
+  describe "PATCH /api/pleroma/admin/reports" do
     setup %{conn: conn} do
       admin = insert(:user, is_admin: true)
       [reporter, target_user] = insert_pair(:user)
@@ -1325,16 +1325,32 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
           "status_ids" => [activity.id]
         })
 
-      %{conn: assign(conn, :user, admin), id: report_id, admin: admin}
+      {:ok, %{id: second_report_id}} =
+        CommonAPI.report(reporter, %{
+          "account_id" => target_user.id,
+          "comment" => "I feel very offended",
+          "status_ids" => [activity.id]
+        })
+
+      %{
+        conn: assign(conn, :user, admin),
+        id: report_id,
+        admin: admin,
+        second_report_id: second_report_id
+      }
     end
 
     test "mark report as resolved", %{conn: conn, id: id, admin: admin} do
-      response =
-        conn
-        |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "resolved"})
-        |> json_response(:ok)
+      conn
+      |> patch("/api/pleroma/admin/reports", %{
+        "reports" => [
+          %{"state" => "resolved", "id" => id}
+        ]
+      })
+      |> json_response(:no_content)
 
-      assert response["state"] == "resolved"
+      activity = Activity.get_by_id(id)
+      assert activity.data["state"] == "resolved"
 
       log_entry = Repo.one(ModerationLog)
 
@@ -1343,12 +1359,16 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
 
     test "closes report", %{conn: conn, id: id, admin: admin} do
-      response =
-        conn
-        |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "closed"})
-        |> json_response(:ok)
+      conn
+      |> patch("/api/pleroma/admin/reports", %{
+        "reports" => [
+          %{"state" => "closed", "id" => id}
+        ]
+      })
+      |> json_response(:no_content)
 
-      assert response["state"] == "closed"
+      activity = Activity.get_by_id(id)
+      assert activity.data["state"] == "closed"
 
       log_entry = Repo.one(ModerationLog)
 
@@ -1359,17 +1379,54 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     test "returns 400 when state is unknown", %{conn: conn, id: id} do
       conn =
         conn
-        |> put("/api/pleroma/admin/reports/#{id}", %{"state" => "test"})
+        |> patch("/api/pleroma/admin/reports", %{
+          "reports" => [
+            %{"state" => "test", "id" => id}
+          ]
+        })
 
-      assert json_response(conn, :bad_request) == "Unsupported state"
+      assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state"
     end
 
     test "returns 404 when report is not exist", %{conn: conn} do
       conn =
         conn
-        |> put("/api/pleroma/admin/reports/test", %{"state" => "closed"})
+        |> patch("/api/pleroma/admin/reports", %{
+          "reports" => [
+            %{"state" => "closed", "id" => "test"}
+          ]
+        })
 
-      assert json_response(conn, :not_found) == "Not found"
+      assert hd(json_response(conn, :bad_request))["error"] == "not_found"
+    end
+
+    test "updates state of multiple reports", %{
+      conn: conn,
+      id: id,
+      admin: admin,
+      second_report_id: second_report_id
+    } do
+      conn
+      |> patch("/api/pleroma/admin/reports", %{
+        "reports" => [
+          %{"state" => "resolved", "id" => id},
+          %{"state" => "closed", "id" => second_report_id}
+        ]
+      })
+      |> json_response(:no_content)
+
+      activity = Activity.get_by_id(id)
+      second_activity = Activity.get_by_id(second_report_id)
+      assert activity.data["state"] == "resolved"
+      assert second_activity.data["state"] == "closed"
+
+      [first_log_entry, second_log_entry] = Repo.all(ModerationLog)
+
+      assert ModerationLog.get_log_entry_message(first_log_entry) ==
+               "@#{admin.nickname} updated report ##{id} with 'resolved' state"
+
+      assert ModerationLog.get_log_entry_message(second_log_entry) ==
+               "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state"
     end
   end
 
@@ -1492,7 +1549,145 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
     end
   end
 
-  #
+  describe "GET /api/pleroma/admin/grouped_reports" do
+    setup %{conn: conn} do
+      admin = insert(:user, is_admin: true)
+      [reporter, target_user] = insert_pair(:user)
+
+      date1 = (DateTime.to_unix(DateTime.utc_now()) + 1000) |> DateTime.from_unix!()
+      date2 = (DateTime.to_unix(DateTime.utc_now()) + 2000) |> DateTime.from_unix!()
+      date3 = (DateTime.to_unix(DateTime.utc_now()) + 3000) |> DateTime.from_unix!()
+
+      first_status =
+        insert(:note_activity, user: target_user, data_attrs: %{"published" => date1})
+
+      second_status =
+        insert(:note_activity, user: target_user, data_attrs: %{"published" => date2})
+
+      third_status =
+        insert(:note_activity, user: target_user, data_attrs: %{"published" => date3})
+
+      {:ok, first_report} =
+        CommonAPI.report(reporter, %{
+          "account_id" => target_user.id,
+          "status_ids" => [first_status.id, second_status.id, third_status.id]
+        })
+
+      {:ok, second_report} =
+        CommonAPI.report(reporter, %{
+          "account_id" => target_user.id,
+          "status_ids" => [first_status.id, second_status.id]
+        })
+
+      {:ok, third_report} =
+        CommonAPI.report(reporter, %{
+          "account_id" => target_user.id,
+          "status_ids" => [first_status.id]
+        })
+
+      %{
+        conn: assign(conn, :user, admin),
+        first_status: Activity.get_by_ap_id_with_object(first_status.data["id"]),
+        second_status: Activity.get_by_ap_id_with_object(second_status.data["id"]),
+        third_status: Activity.get_by_ap_id_with_object(third_status.data["id"]),
+        first_status_reports: [first_report, second_report, third_report],
+        second_status_reports: [first_report, second_report],
+        third_status_reports: [first_report],
+        target_user: target_user,
+        reporter: reporter
+      }
+    end
+
+    test "returns reports grouped by status", %{
+      conn: conn,
+      first_status: first_status,
+      second_status: second_status,
+      third_status: third_status,
+      first_status_reports: first_status_reports,
+      second_status_reports: second_status_reports,
+      third_status_reports: third_status_reports,
+      target_user: target_user,
+      reporter: reporter
+    } do
+      response =
+        conn
+        |> get("/api/pleroma/admin/grouped_reports")
+        |> json_response(:ok)
+
+      assert length(response["reports"]) == 3
+
+      first_group =
+        Enum.find(response["reports"], &(&1["status"]["id"] == first_status.data["id"]))
+
+      second_group =
+        Enum.find(response["reports"], &(&1["status"]["id"] == second_status.data["id"]))
+
+      third_group =
+        Enum.find(response["reports"], &(&1["status"]["id"] == third_status.data["id"]))
+
+      assert length(first_group["reports"]) == 3
+      assert length(second_group["reports"]) == 2
+      assert length(third_group["reports"]) == 1
+
+      assert first_group["date"] ==
+               Enum.max_by(first_status_reports, fn act ->
+                 NaiveDateTime.from_iso8601!(act.data["published"])
+               end).data["published"]
+
+      assert first_group["status"] == %{
+               "id" => first_status.data["id"],
+               "content" => first_status.object.data["content"],
+               "published" => first_status.object.data["published"]
+             }
+
+      assert first_group["account"]["id"] == target_user.id
+
+      assert length(first_group["actors"]) == 1
+      assert hd(first_group["actors"])["id"] == reporter.id
+
+      assert Enum.map(first_group["reports"], & &1["id"]) --
+               Enum.map(first_status_reports, & &1.id) == []
+
+      assert second_group["date"] ==
+               Enum.max_by(second_status_reports, fn act ->
+                 NaiveDateTime.from_iso8601!(act.data["published"])
+               end).data["published"]
+
+      assert second_group["status"] == %{
+               "id" => second_status.data["id"],
+               "content" => second_status.object.data["content"],
+               "published" => second_status.object.data["published"]
+             }
+
+      assert second_group["account"]["id"] == target_user.id
+
+      assert length(second_group["actors"]) == 1
+      assert hd(second_group["actors"])["id"] == reporter.id
+
+      assert Enum.map(second_group["reports"], & &1["id"]) --
+               Enum.map(second_status_reports, & &1.id) == []
+
+      assert third_group["date"] ==
+               Enum.max_by(third_status_reports, fn act ->
+                 NaiveDateTime.from_iso8601!(act.data["published"])
+               end).data["published"]
+
+      assert third_group["status"] == %{
+               "id" => third_status.data["id"],
+               "content" => third_status.object.data["content"],
+               "published" => third_status.object.data["published"]
+             }
+
+      assert third_group["account"]["id"] == target_user.id
+
+      assert length(third_group["actors"]) == 1
+      assert hd(third_group["actors"])["id"] == reporter.id
+
+      assert Enum.map(third_group["reports"], & &1["id"]) --
+               Enum.map(third_status_reports, & &1.id) == []
+    end
+  end
+
   describe "POST /api/pleroma/admin/reports/:id/respond" do
     setup %{conn: conn} do
       admin = insert(:user, is_admin: true)
@@ -2269,6 +2464,10 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
       Pleroma.Config.put([:instance, :dynamic_configuration], true)
     end
 
+    clear_config([:feed, :post_title]) do
+      Pleroma.Config.put([:feed, :post_title], %{max_length: 100, omission: "…"})
+    end
+
     test "transfer settings to DB and to file", %{conn: conn, admin: admin} do
       assert Pleroma.Repo.all(Pleroma.Web.AdminAPI.Config) == []
       conn = get(conn, "/api/pleroma/admin/config/migrate_to_db")
index 8e6fbd7f09c65f2795eb9dac90cb2b95996341b2..138488d44b1626f8ea7fe5ec5ce140794a5d7c71 100644 (file)
@@ -227,6 +227,33 @@ defmodule Pleroma.Web.CommonAPITest do
   end
 
   describe "reactions" do
+    test "reacting to a status with an emoji" do
+      user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
+
+      {:ok, reaction, _} = CommonAPI.react_with_emoji(activity.id, user, "👍")
+
+      assert reaction.data["actor"] == user.ap_id
+      assert reaction.data["content"] == "👍"
+
+      # TODO: test error case.
+    end
+
+    test "unreacting to a status with an emoji" do
+      user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"})
+      {:ok, reaction, _} = CommonAPI.react_with_emoji(activity.id, user, "👍")
+
+      {:ok, unreaction, _} = CommonAPI.unreact_with_emoji(activity.id, user, "👍")
+
+      assert unreaction.data["type"] == "Undo"
+      assert unreaction.data["object"] == reaction.data["id"]
+    end
+
     test "repeating a status" do
       user = insert(:user)
       other_user = insert(:user)
@@ -441,6 +468,35 @@ defmodule Pleroma.Web.CommonAPITest do
 
       assert CommonAPI.update_report_state(report_id, "test") == {:error, "Unsupported state"}
     end
+
+    test "updates state of multiple reports" do
+      [reporter, target_user] = insert_pair(:user)
+      activity = insert(:note_activity, user: target_user)
+
+      {:ok, %Activity{id: first_report_id}} =
+        CommonAPI.report(reporter, %{
+          "account_id" => target_user.id,
+          "comment" => "I feel offended",
+          "status_ids" => [activity.id]
+        })
+
+      {:ok, %Activity{id: second_report_id}} =
+        CommonAPI.report(reporter, %{
+          "account_id" => target_user.id,
+          "comment" => "I feel very offended!",
+          "status_ids" => [activity.id]
+        })
+
+      {:ok, report_ids} =
+        CommonAPI.update_report_state([first_report_id, second_report_id], "resolved")
+
+      first_report = Activity.get_by_id(first_report_id)
+      second_report = Activity.get_by_id(second_report_id)
+
+      assert report_ids -- [first_report_id, second_report_id] == []
+      assert first_report.data["state"] == "resolved"
+      assert second_report.data["state"] == "resolved"
+    end
   end
 
   describe "reblog muting" do
index a3281b25b7235036797bffd055ebce706d41be2e..6cc8766020d02f4943a8ca71b4dd8c6083878f90 100644 (file)
@@ -84,6 +84,30 @@ defmodule Pleroma.Web.NodeInfoTest do
     Pleroma.Config.put([:instance, :safe_dm_mentions], option)
   end
 
+  test "it shows if federation is enabled/disabled", %{conn: conn} do
+    original = Pleroma.Config.get([:instance, :federating])
+
+    Pleroma.Config.put([:instance, :federating], true)
+
+    response =
+      conn
+      |> get("/nodeinfo/2.1.json")
+      |> json_response(:ok)
+
+    assert response["metadata"]["federation"]["enabled"] == true
+
+    Pleroma.Config.put([:instance, :federating], false)
+
+    response =
+      conn
+      |> get("/nodeinfo/2.1.json")
+      |> json_response(:ok)
+
+    assert response["metadata"]["federation"]["enabled"] == false
+
+    Pleroma.Config.put([:instance, :federating], original)
+  end
+
   test "it shows MRF transparency data if enabled", %{conn: conn} do
     config = Pleroma.Config.get([:instance, :rewrite_policy])
     Pleroma.Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy])
index ad8d7908363f99f18fd213477f5b4c408a634448..beb995cd8435f5050eb77023c9fa906baa36d947 100644 (file)
@@ -469,6 +469,29 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
       assert html_response(conn, 200) =~ ~s(type="submit")
     end
 
+    test "renders authentication page if user is already authenticated but user request with another client",
+         %{
+           app: app,
+           conn: conn
+         } do
+      token = insert(:oauth_token, app_id: app.id)
+
+      conn =
+        conn
+        |> put_session(:oauth_token, token.token)
+        |> get(
+          "/oauth/authorize",
+          %{
+            "response_type" => "code",
+            "client_id" => "another_client_id",
+            "redirect_uri" => OAuthController.default_redirect_uri(app),
+            "scope" => "read"
+          }
+        )
+
+      assert html_response(conn, 200) =~ ~s(type="submit")
+    end
+
     test "with existing authentication and non-OOB `redirect_uri`, redirects to app with `token` and `state` params",
          %{
            app: app,
index 0c83edb56173785cf70a8f640af146083fb1d9f2..b1b59beed8910ee969b54d5a1031421fd4d874f8 100644 (file)
@@ -7,12 +7,72 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
 
   alias Pleroma.Conversation.Participation
   alias Pleroma.Notification
+  alias Pleroma.Object
   alias Pleroma.Repo
   alias Pleroma.User
   alias Pleroma.Web.CommonAPI
 
   import Pleroma.Factory
 
+  test "POST /api/v1/pleroma/statuses/:id/react_with_emoji", %{conn: conn} do
+    user = insert(:user)
+    other_user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"})
+
+    result =
+      conn
+      |> assign(:user, other_user)
+      |> post("/api/v1/pleroma/statuses/#{activity.id}/react_with_emoji", %{"emoji" => "☕"})
+
+    assert %{"id" => id} = json_response(result, 200)
+    assert to_string(activity.id) == id
+  end
+
+  test "POST /api/v1/pleroma/statuses/:id/unreact_with_emoji", %{conn: conn} do
+    user = insert(:user)
+    other_user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"})
+    {:ok, activity, _object} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
+
+    result =
+      conn
+      |> assign(:user, other_user)
+      |> post("/api/v1/pleroma/statuses/#{activity.id}/unreact_with_emoji", %{"emoji" => "☕"})
+
+    assert %{"id" => id} = json_response(result, 200)
+    assert to_string(activity.id) == id
+
+    object = Object.normalize(activity)
+
+    assert object.data["reaction_count"] == 0
+  end
+
+  test "GET /api/v1/pleroma/statuses/:id/emoji_reactions_by", %{conn: conn} do
+    user = insert(:user)
+    other_user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"})
+
+    result =
+      conn
+      |> get("/api/v1/pleroma/statuses/#{activity.id}/emoji_reactions_by")
+      |> json_response(200)
+
+    assert result == %{}
+
+    {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅")
+
+    result =
+      conn
+      |> get("/api/v1/pleroma/statuses/#{activity.id}/emoji_reactions_by")
+      |> json_response(200)
+
+    [represented_user] = result["🎅"]
+    assert represented_user["id"] == other_user.id
+  end
+
   test "/api/v1/pleroma/conversations/:id", %{conn: conn} do
     user = insert(:user)
     other_user = insert(:user)
diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs
new file mode 100644 (file)
index 0000000..2ce8f9f
--- /dev/null
@@ -0,0 +1,210 @@
+defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do
+  use Pleroma.Web.ConnCase
+  alias Pleroma.Activity
+  alias Pleroma.Web.ActivityPub.Transmogrifier
+  alias Pleroma.Web.CommonAPI
+
+  import Pleroma.Factory
+
+  clear_config_all([:static_fe, :enabled]) do
+    Pleroma.Config.put([:static_fe, :enabled], true)
+  end
+
+  describe "user profile page" do
+    test "just the profile as HTML", %{conn: conn} do
+      user = insert(:user)
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/users/#{user.nickname}")
+
+      assert html_response(conn, 200) =~ user.nickname
+    end
+
+    test "renders json unless there's an html accept header", %{conn: conn} do
+      user = insert(:user)
+
+      conn =
+        conn
+        |> put_req_header("accept", "application/json")
+        |> get("/users/#{user.nickname}")
+
+      assert json_response(conn, 200)
+    end
+
+    test "404 when user not found", %{conn: conn} do
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/users/limpopo")
+
+      assert html_response(conn, 404) =~ "not found"
+    end
+
+    test "profile does not include private messages", %{conn: conn} do
+      user = insert(:user)
+      CommonAPI.post(user, %{"status" => "public"})
+      CommonAPI.post(user, %{"status" => "private", "visibility" => "private"})
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/users/#{user.nickname}")
+
+      html = html_response(conn, 200)
+
+      assert html =~ ">public<"
+      refute html =~ ">private<"
+    end
+
+    test "pagination", %{conn: conn} do
+      user = insert(:user)
+      Enum.map(1..30, fn i -> CommonAPI.post(user, %{"status" => "test#{i}"}) end)
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/users/#{user.nickname}")
+
+      html = html_response(conn, 200)
+
+      assert html =~ ">test30<"
+      assert html =~ ">test11<"
+      refute html =~ ">test10<"
+      refute html =~ ">test1<"
+    end
+
+    test "pagination, page 2", %{conn: conn} do
+      user = insert(:user)
+      activities = Enum.map(1..30, fn i -> CommonAPI.post(user, %{"status" => "test#{i}"}) end)
+      {:ok, a11} = Enum.at(activities, 11)
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/users/#{user.nickname}?max_id=#{a11.id}")
+
+      html = html_response(conn, 200)
+
+      assert html =~ ">test1<"
+      assert html =~ ">test10<"
+      refute html =~ ">test20<"
+      refute html =~ ">test29<"
+    end
+  end
+
+  describe "notice rendering" do
+    test "single notice page", %{conn: conn} do
+      user = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "testing a thing!"})
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/notice/#{activity.id}")
+
+      html = html_response(conn, 200)
+      assert html =~ "<header>"
+      assert html =~ user.nickname
+      assert html =~ "testing a thing!"
+    end
+
+    test "shows the whole thread", %{conn: conn} do
+      user = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "space: the final frontier"})
+
+      CommonAPI.post(user, %{
+        "status" => "these are the voyages or something",
+        "in_reply_to_status_id" => activity.id
+      })
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/notice/#{activity.id}")
+
+      html = html_response(conn, 200)
+      assert html =~ "the final frontier"
+      assert html =~ "voyages"
+    end
+
+    test "redirect by AP object ID", %{conn: conn} do
+      user = insert(:user)
+
+      {:ok, %Activity{data: %{"object" => object_url}}} =
+        CommonAPI.post(user, %{"status" => "beam me up"})
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get(URI.parse(object_url).path)
+
+      assert html_response(conn, 302) =~ "redirected"
+    end
+
+    test "redirect by activity ID", %{conn: conn} do
+      user = insert(:user)
+
+      {:ok, %Activity{data: %{"id" => id}}} =
+        CommonAPI.post(user, %{"status" => "I'm a doctor, not a devops!"})
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get(URI.parse(id).path)
+
+      assert html_response(conn, 302) =~ "redirected"
+    end
+
+    test "404 when notice not found", %{conn: conn} do
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/notice/88c9c317")
+
+      assert html_response(conn, 404) =~ "not found"
+    end
+
+    test "404 for private status", %{conn: conn} do
+      user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(user, %{"status" => "don't show me!", "visibility" => "private"})
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/notice/#{activity.id}")
+
+      assert html_response(conn, 404) =~ "not found"
+    end
+
+    test "302 for remote cached status", %{conn: conn} 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)
+
+      conn =
+        conn
+        |> put_req_header("accept", "text/html")
+        |> get("/notice/#{activity.id}")
+
+      assert html_response(conn, 302) =~ "redirected"
+    end
+  end
+end