Merge pull request '2022.09 stable' (#208) from develop into stable
authorfloatingghost <hannah@coffee-and-dreams.uk>
Sat, 10 Sep 2022 16:06:18 +0000 (16:06 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Sat, 10 Sep 2022 16:06:18 +0000 (16:06 +0000)
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/208

153 files changed:
.gitlab/issue_templates/Bug.md [deleted file]
.gitlab/merge_request_templates/Release.md [deleted file]
.woodpecker.yml
CHANGELOG.md
README.md
SIGNING_KEY.pub [new file with mode: 0644]
config/config.exs
config/description.exs
docs/docs/configuration/cheatsheet.md
docs/docs/configuration/frontend_management.md
docs/docs/development/API/differences_in_mastoapi_responses.md
docs/docs/installation/alpine_linux_en.md
docs/docs/installation/arch_linux_en.md
docs/docs/installation/debian_based_en.md
docs/docs/installation/fedora_based_en.md
docs/docs/installation/freebsd_en.md
docs/docs/installation/frontends.include [new file with mode: 0644]
docs/docs/installation/gentoo_en.md
docs/docs/installation/migrating_to_akkoma.md
docs/docs/installation/netbsd_en.md
docs/docs/installation/openbsd_en.md
docs/docs/installation/otp_en.md
docs/docs/installation/otp_redhat_en.md
docs/docs/installation/verifying_otp_releases.md [new file with mode: 0644]
lib/mix/tasks/pleroma/search/meilisearch.ex
lib/pleroma/activity/html.ex
lib/pleroma/akkoma/translators/deepl.ex [new file with mode: 0644]
lib/pleroma/akkoma/translators/libre_translate.ex [new file with mode: 0644]
lib/pleroma/akkoma/translators/translator.ex [new file with mode: 0644]
lib/pleroma/application.ex
lib/pleroma/collections/fetcher.ex
lib/pleroma/config/transfer_task.ex
lib/pleroma/constants.ex
lib/pleroma/emoji.ex
lib/pleroma/helpers/auth_helper.ex
lib/pleroma/notification.ex
lib/pleroma/object/fetcher.ex
lib/pleroma/object/updater.ex [new file with mode: 0644]
lib/pleroma/release_tasks.ex
lib/pleroma/search/elasticsearch.ex
lib/pleroma/search/elasticsearch/store.ex
lib/pleroma/upload.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/builder.ex
lib/pleroma/web/activity_pub/mrf.ex
lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex
lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex
lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex
lib/pleroma/web/activity_pub/mrf/keyword_policy.ex
lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex
lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex
lib/pleroma/web/activity_pub/mrf/normalize_markup.ex
lib/pleroma/web/activity_pub/mrf/policy.ex
lib/pleroma/web/activity_pub/mrf/simple_policy.ex
lib/pleroma/web/activity_pub/object_validator.ex
lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex
lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
lib/pleroma/web/activity_pub/object_validators/common_fields.ex
lib/pleroma/web/activity_pub/object_validators/common_fixes.ex
lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex
lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex
lib/pleroma/web/activity_pub/object_validators/update_validator.ex
lib/pleroma/web/activity_pub/side_effects.ex
lib/pleroma/web/activity_pub/transmogrifier.ex
lib/pleroma/web/activity_pub/utils.ex
lib/pleroma/web/akkoma_api/controllers/translation_controller.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/operations/status_operation.ex
lib/pleroma/web/api_spec/operations/translate_operation.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/operations/twitter_util_operation.ex
lib/pleroma/web/api_spec/schemas/status.ex
lib/pleroma/web/common_api.ex
lib/pleroma/web/common_api/activity_draft.ex
lib/pleroma/web/common_api/utils.ex
lib/pleroma/web/masto_fe_controller.ex
lib/pleroma/web/mastodon_api/controllers/auth_controller.ex
lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
lib/pleroma/web/mastodon_api/controllers/status_controller.ex
lib/pleroma/web/mastodon_api/views/instance_view.ex
lib/pleroma/web/mastodon_api/views/notification_view.ex
lib/pleroma/web/mastodon_api/views/status_view.ex
lib/pleroma/web/mastodon_api/websocket_handler.ex
lib/pleroma/web/o_auth/authorization.ex
lib/pleroma/web/o_auth/o_auth_controller.ex
lib/pleroma/web/o_auth/token.ex
lib/pleroma/web/o_auth/token/query.ex
lib/pleroma/web/o_auth/token/strategy/revoke.ex
lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex
lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex
lib/pleroma/web/plugs/http_signature_plug.ex
lib/pleroma/web/router.ex
lib/pleroma/web/streamer.ex
lib/pleroma/web/templates/masto_fe/fedibird.index.html.eex [new file with mode: 0644]
lib/pleroma/web/templates/masto_fe/glitchsoc.index.html.eex [moved from lib/pleroma/web/templates/masto_fe/index.html.eex with 100% similarity]
lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex [new file with mode: 0644]
lib/pleroma/web/twitter_api/controllers/util_controller.ex
lib/pleroma/web/twitter_api/views/util_view.ex
lib/pleroma/web/views/masto_fe_view.ex
lib/pleroma/web/views/streamer_view.ex
mix.exs
mix.lock
priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs [new file with mode: 0644]
priv/repo/migrations/20220831170605_remove_local_cancelled_follows.exs [new file with mode: 0644]
priv/scrubbers/default.ex
priv/static/index.html
priv/static/schemas/litepub-0.1.jsonld
rel/files/installation/akkoma.service [moved from rel/files/installation/pleroma.service with 63% similarity]
rel/files/installation/init.d/akkoma [moved from rel/files/installation/init.d/pleroma with 70% similarity]
test/fixtures/misskey/mfm_x_format.json
test/mix/tasks/pleroma/relay_test.exs
test/pleroma/collections/collections_fetcher_test.exs
test/pleroma/config/transfer_task_test.exs
test/pleroma/integration/mastodon_websocket_test.exs
test/pleroma/notification_test.exs
test/pleroma/object/fetcher_test.exs
test/pleroma/object/updater_test.exs [new file with mode: 0644]
test/pleroma/translators/deepl_test.exs [new file with mode: 0644]
test/pleroma/translators/libre_translate_test.exs [new file with mode: 0644]
test/pleroma/upload_test.exs
test/pleroma/web/activity_pub/activity_pub_controller_test.exs
test/pleroma/web/activity_pub/activity_pub_test.exs
test/pleroma/web/activity_pub/builder_test.exs
test/pleroma/web/activity_pub/mrf/anti_link_spam_policy_test.exs
test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs
test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs
test/pleroma/web/activity_pub/mrf/keyword_policy_test.exs
test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs
test/pleroma/web/activity_pub/mrf/no_empty_policy_test.exs
test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs
test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs
test/pleroma/web/activity_pub/mrf/simple_policy_test.exs
test/pleroma/web/activity_pub/mrf_test.exs
test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs
test/pleroma/web/activity_pub/object_validators/update_handling_test.exs
test/pleroma/web/activity_pub/side_effects_test.exs
test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs
test/pleroma/web/activity_pub/transmogrifier_test.exs
test/pleroma/web/activity_pub/utils_test.exs
test/pleroma/web/common_api_test.exs
test/pleroma/web/masto_fe_controller_test.exs [new file with mode: 0644]
test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs
test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
test/pleroma/web/mastodon_api/views/notification_view_test.exs
test/pleroma/web/mastodon_api/views/status_view_test.exs
test/pleroma/web/metadata/utils_test.exs
test/pleroma/web/o_auth/o_auth_controller_test.exs
test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs
test/pleroma/web/plugs/http_signature_plug_test.exs
test/pleroma/web/streamer_test.exs
test/pleroma/web/twitter_api/util_controller_test.exs
test/support/factory.ex
test/support/http_request_mock.ex
test/support/websocket_client.ex

diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md
deleted file mode 100644 (file)
index dd0d6eb..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-<!--
-### Precheck
-
-* For support use https://git.pleroma.social/pleroma/pleroma-support or [community channels](https://git.pleroma.social/pleroma/pleroma#community-channels).
-* Please do a quick search to ensure no similar bug has been reported before. If the bug has not been addressed after 2 weeks, it's fine to bump it.
-* Try to ensure that the bug is actually related to the Pleroma backend. For example, if a bug happens in Pleroma-FE but not in Mastodon-FE or mobile clients, it's likely that the bug should be filed in [Pleroma-FE](https://git.pleroma.social/pleroma/pleroma-fe/issues/new) repository.
--->
-
-### Environment
-
-* Installation type (OTP or From Source):
-* Pleroma version (could be found in the "Version" tab of settings in Pleroma-FE): 
-* Elixir version (`elixir -v` for from source installations, N/A for OTP):
-* Operating system:
-* PostgreSQL version (`psql -V`):
-
-
-### Bug description
diff --git a/.gitlab/merge_request_templates/Release.md b/.gitlab/merge_request_templates/Release.md
deleted file mode 100644 (file)
index b2c7726..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-### Release checklist
-* [ ]  Bump version in `mix.exs`
-* [ ]  Compile a changelog
-* [ ]  Create an MR with an announcement to pleroma.social
-* [ ]  Tag the release
-* [ ] Merge `stable` into `develop` (in case the fixes are already in develop, use `git merge -s ours --no-commit` and manually merge the changelogs)
index 32db2f1c5a8c160033e4df0833dc2e0e2cc09918..955bbe7fd59b611083ce42b401c5c7c136cc47fb 100644 (file)
@@ -112,7 +112,7 @@ pipeline:
       - /bin/sh /entrypoint.sh
 
   debian-bullseye:
-    image: elixir:1.13.4
+    image: akkoma/debian
     <<: *on-release
     environment:
       MIX_ENV: prod
index 7c7cd86014a699aa8220e9a910314f6e8b8dd826..96b80693c2226a6f76736bac375e446101395fbe 100644 (file)
@@ -4,7 +4,33 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
-## [Unreleased]
+## 2022.09
+
+### Added
+- support for fedibird-fe, and non-breaking API parity for it to function
+- support for setting instance languages in metadata
+- support for reusing oauth tokens, and not requiring new authorizations
+- the ability to obfuscate domains in your MRF descriptions
+- automatic translation of statuses via DeepL or LibreTranslate
+- ability to edit posts
+- ability to react with remote emoji
+
+### Changed
+- MFM parsing is now done on the backend by a modified version of ilja's parser -> https://akkoma.dev/AkkomaGang/mfm-parser
+- InlineQuotePolicy is now on by default
+
+### Fixed
+- Compatibility with latest meilisearch
+- Resolution of nested mix tasks (i.e search.meilisearch) in OTP releases
+- Elasticsearch returning likes and repeats, displaying as posts
+- Ensure key generation happens at registration-time to prevent potential race-conditions
+- Ensured websockets get closed on logout
+- Allowed GoToSocial-style `?query_string` signatures
+
+### Removed
+- Non-finch HTTP adapters. `:tesla, :adapter` is now highly recommended to be set to the default.
+
+## 2022.08
 
 ### Removed
 - Non-finch HTTP adapters. `:tesla, :adapter` is now highly recommended to be set to the default.
@@ -23,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
   - amd64 is built for debian stable. Compatible with ubuntu 20.
   - ubuntu-jammy is built for... well, ubuntu 22 (LTS)
   - amd64-musl is built for alpine 3.16
+- Enable remote users to interact with posts
 
 ### Fixed
 - Updated mastoFE path, for the newer version
index 5debafaea04057a7d29cc00ed4ae4f9c5708985e..c3ead7fc1a45c717abb0b66ab42b794e9a407000 100644 (file)
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
 
 *a smallish microblogging platform, aka the cooler pleroma*
 
+![English OK](https://img.shields.io/badge/English-OK-blueviolet) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet)
+
 ## About 
 
 This is a fork of Pleroma, which is a microblogging server software that can federate (= exchange messages with) other servers that support ActivityPub. What that means is that you can host a server for yourself or your friends and stay in control of your online identity, but still exchange messages with people on larger servers. Akkoma will federate with all servers that implement ActivityPub, like Friendica, GNU Social, Hubzilla, Mastodon, Misskey, Peertube, and Pixelfed.
diff --git a/SIGNING_KEY.pub b/SIGNING_KEY.pub
new file mode 100644 (file)
index 0000000..7d8b48d
--- /dev/null
@@ -0,0 +1,2 @@
+untrusted comment: Akkoma Signing Key public key
+RWQRlw8Ex/uTbvo1wB1yK75tQ5nXKilB/vrKdkL41bgZHL9aKP+7fSS5
index f49ec861c0f3329806315bea741b0faa67fee79f..bf7e7db443de32e44107f5d0a449f8f64b8cb00e 100644 (file)
@@ -197,6 +197,7 @@ config :pleroma, :instance,
   avatar_upload_limit: 2_000_000,
   background_upload_limit: 4_000_000,
   banner_upload_limit: 4_000_000,
+  languages: ["en"],
   poll_limits: %{
     max_options: 20,
     max_option_chars: 200,
@@ -734,6 +735,14 @@ config :pleroma, :frontends,
       "build_dir" => "distribution",
       "ref" => "akkoma"
     },
+    "fedibird-fe" => %{
+      "name" => "fedibird-fe",
+      "git" => "https://akkoma.dev/AkkomaGang/fedibird-fe",
+      "build_url" =>
+        "https://akkoma-updates.s3-website.fr-par.scw.cloud/frontend/${ref}/fedibird-fe.zip",
+      "build_dir" => "distribution",
+      "ref" => "akkoma"
+    },
     "admin-fe" => %{
       "name" => "admin-fe",
       "git" => "https://akkoma.dev/AkkomaGang/admin-fe",
@@ -785,7 +794,8 @@ config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false
 config :pleroma, :mrf,
   policies: [Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, Pleroma.Web.ActivityPub.MRF.TagPolicy],
   transparency: true,
-  transparency_exclusions: []
+  transparency_exclusions: [],
+  transparency_obfuscate_domains: []
 
 config :ex_aws, http_client: Pleroma.HTTP.ExAws
 
@@ -833,6 +843,19 @@ config :pleroma, Pleroma.Search.Elasticsearch.Cluster,
     }
   }
 
+config :pleroma, :translator,
+  enabled: false,
+  module: Pleroma.Akkoma.Translators.DeepL
+
+config :pleroma, :deepl,
+  # either :free or :pro
+  tier: :free,
+  api_key: ""
+
+config :pleroma, :libre_translate,
+  url: "http://127.0.0.1:5000",
+  api_key: nil
+
 # Import environment specific config. This must remain at the bottom
 # of this file so it overrides the configuration defined above.
 import_config "#{Mix.env()}.exs"
index 9f93265d1ff1ba9cd764634a30d029d39fa827d7..a17897b98a08f0cbd70159ee781b498e5008b6f6 100644 (file)
@@ -509,6 +509,16 @@ config :pleroma, :config_description, [
           "Pleroma"
         ]
       },
+      %{
+        key: :languages,
+        type: {:list, :string},
+        description: "Languages the instance uses",
+        suggestions: [
+          "en",
+          "ja",
+          "fr"
+        ]
+      },
       %{
         key: :email,
         label: "Admin Email Address",
@@ -1169,7 +1179,6 @@ config :pleroma, :config_description, [
             hideFilteredStatuses: false,
             hideMutedPosts: false,
             hidePostStats: false,
-            hideSitename: false,
             hideUserStats: false,
             loginMethod: "password",
             logo: "/static/logo.svg",
@@ -1235,12 +1244,6 @@ config :pleroma, :config_description, [
             type: :boolean,
             description: "Hide notices statistics (repeats, favorites, ...)"
           },
-          %{
-            key: :hideSitename,
-            label: "Hide Sitename",
-            type: :boolean,
-            description: "Hides instance name from PleromaFE banner"
-          },
           %{
             key: :hideUserStats,
             label: "Hide user stats",
@@ -1350,6 +1353,42 @@ config :pleroma, :config_description, [
             type: :string,
             description: "Which theme to use. Available themes are defined in styles.json",
             suggestions: ["pleroma-dark"]
+          },
+          %{
+            key: :showPanelNavShortcuts,
+            label: "Show timeline panel nav shortcuts",
+            type: :boolean,
+            description: "Whether to put timeline nav tabs on the top of the panel"
+          },
+          %{
+            key: :showNavShortcuts,
+            label: "Show navbar shortcuts",
+            type: :boolean,
+            description: "Whether to put extra navigation options on the navbar"
+          },
+          %{
+            key: :showWiderShortcuts,
+            label: "Increase navbar shortcut spacing",
+            type: :boolean,
+            description: "Whether to add extra space between navbar icons"
+          },
+          %{
+            key: :hideSiteFavicon,
+            label: "Hide site favicon",
+            type: :boolean,
+            description: "Whether to hide the instance favicon from the navbar"
+          },
+          %{
+            key: :hideSiteName,
+            label: "Hide site name",
+            type: :boolean,
+            description: "Whether to hide the site name from the navbar"
+          },
+          %{
+            key: :renderMisskeyMarkdown,
+            label: "Render misskey markdown",
+            type: :boolean,
+            description: "Whether to render Misskey-flavoured markdown"
           }
         ]
       },
@@ -3187,13 +3226,14 @@ config :pleroma, :config_description, [
     group: :pleroma,
     key: Pleroma.Search,
     type: :group,
+    label: "Search",
     description: "General search settings.",
     children: [
       %{
         key: :module,
-        type: :keyword,
+        type: :module,
         description: "Selected search module.",
-        suggestion: [Pleroma.Search.DatabaseSearch, Pleroma.Search.Meilisearch]
+        suggestions: {:list_behaviour_implementations, Pleroma.Search.SearchBackend}
       }
     ]
   },
@@ -3218,7 +3258,7 @@ config :pleroma, :config_description, [
       },
       %{
         key: :initial_indexing_chunk_size,
-        type: :int,
+        type: :integer,
         description:
           "Amount of posts in a batch when running the initial indexing operation. Should probably not be more than 100000" <>
             " since there's a limit on maximum insert size",
@@ -3229,6 +3269,7 @@ config :pleroma, :config_description, [
   %{
     group: :pleroma,
     key: Pleroma.Search.Elasticsearch.Cluster,
+    label: "Elasticsearch",
     type: :group,
     description: "Elasticsearch settings.",
     children: [
@@ -3295,13 +3336,13 @@ config :pleroma, :config_description, [
               },
               %{
                 key: :bulk_page_size,
-                type: :int,
+                type: :integer,
                 description: "Size for bulk put requests, mostly used on building the index",
                 suggestion: [5000]
               },
               %{
                 key: :bulk_wait_interval,
-                type: :int,
+                type: :integer,
                 description: "Time to wait between bulk put requests (in ms)",
                 suggestion: [15_000]
               }
@@ -3310,5 +3351,66 @@ config :pleroma, :config_description, [
         ]
       }
     ]
+  },
+  %{
+    group: :pleroma,
+    key: :translator,
+    type: :group,
+    description: "Translation Settings",
+    children: [
+      %{
+        key: :enabled,
+        type: :boolean,
+        description: "Is translation enabled?",
+        suggestion: [true, false]
+      },
+      %{
+        key: :module,
+        type: :module,
+        description: "Translation module.",
+        suggestions: {:list_behaviour_implementations, Pleroma.Akkoma.Translator}
+      }
+    ]
+  },
+  %{
+    group: :pleroma,
+    key: :deepl,
+    label: "DeepL",
+    type: :group,
+    description: "DeepL Settings.",
+    children: [
+      %{
+        key: :tier,
+        type: {:dropdown, :atom},
+        description: "API Tier",
+        suggestions: [:free, :pro]
+      },
+      %{
+        key: :api_key,
+        type: :string,
+        description: "API key for DeepL",
+        suggestions: [nil]
+      }
+    ]
+  },
+  %{
+    group: :pleroma,
+    key: :libre_translate,
+    type: :group,
+    description: "LibreTranslate Settings.",
+    children: [
+      %{
+        key: :url,
+        type: :string,
+        description: "URL for libretranslate",
+        suggestion: [nil]
+      },
+      %{
+        key: :api_key,
+        type: :string,
+        description: "API key for libretranslate",
+        suggestion: [nil]
+      }
+    ]
   }
 ]
index 8fa188de18ba0e8bf1e048671775d231a776619f..52062eaa0509057b24d8ce392ad5f1a2c0f9a89c 100644 (file)
@@ -120,6 +120,7 @@ To add configuration to your config file, you can copy it from the base config.
     * `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)).
 * `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo).
 * `transparency_exclusions`: Exclude specific instance names from MRF transparency.  The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.
+* `transparency_obfuscate_domains`: Show domains with `*` in the middle, to censor them if needed. For example, `ridingho.me` will show as `rid*****.me`
 
 ## Federation
 ### MRF policies
@@ -1158,3 +1159,28 @@ Each job has these settings:
 
 * `:max_running` - max concurrently runnings jobs
 * `:max_waiting` - max waiting jobs
+
+### Translation Settings
+
+Settings to automatically translate statuses for end users. Currently supported
+translation services are DeepL and LibreTranslate.
+
+Translations are available at `/api/v1/statuses/:id/translations/:language`, where
+`language` is the target language code (e.g `en`)
+
+### `:translator`
+
+- `:enabled` - enables translation
+- `:module` - Sets module to be used
+  - Either `Pleroma.Akkoma.Translators.DeepL` or `Pleroma.Akkoma.Translators.LibreTranslate`
+
+### `:deepl`
+
+- `:api_key` - API key for DeepL
+- `:tier` - API tier
+  - either `:free` or `:pro`
+
+### `:libre_translate`
+
+- `:url` - URL of LibreTranslate instance
+- `:api_key` - API key for LibreTranslate
index a251205894ec9d215e57fd502480abdd91b05576..5e4b9b051e187c1bd76624ac3c6324c538431110 100644 (file)
@@ -19,6 +19,10 @@ config :pleroma, :frontends,
   admin: %{
     "name" => "admin-fe",
     "ref" => "stable"
+  },
+  mastodon: %{
+    "name" => "mastodon-fe",
+    "ref" => "akkoma"
   }
 ```
 
@@ -26,12 +30,18 @@ This would serve the frontend from the the folder at `$instance_static/frontends
 
 Refer to [the frontend CLI task](../../administration/CLI_tasks/frontend) for how to install the frontend's files
 
-If you wish masto-fe to also be enabled, you will also need to run the install task for `mastodon-fe`. Not doing this will lead to the frontend not working.
-
 If you choose not to install a frontend for whatever reason, it is recommended that you enable [`:static_fe`](#static_fe) to allow remote users to click "view remote source". Don't bother with this if you've got no unauthenticated access though.
 
 You can also replace the default "no frontend" page by placing an `index.html` file under your `instance/static/` directory.
 
+## Mastodon-FE
+
+Akkoma supports both [glitchsoc](https://github.com/glitch-soc/mastodon)'s more "vanilla" mastodon frontend,
+as well as [fedibird](https://github.com/fedibird/mastodon)'s extended frontend which has near-feature-parity with akkoma (with quoting and reactions).
+
+To enable either one, you must run the `frontend.install` task for either `mastodon-fe` or `fedibird-fe` (both `--ref akkoma`), then make sure
+`:pleroma, :frontends, :mastodon` references the one you want.
+
 ## Swagger (openAPI) documentation viewer
 
 If you're a developer and you'd like a human-readable rendering of the
index 4465784bf2ee5579cc1c071ad4ec35a9efbfeed7..752be1762e76a06634d8a49fda2691fbf6230fc6 100644 (file)
@@ -40,6 +40,10 @@ Has these additional fields under the `pleroma` object:
 - `parent_visible`: If the parent of this post is visible to the user or not.
 - `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise.
 
+The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes:
+
+- `content_type`: The content type of the status source.
+
 ## Scheduled statuses
 
 Has these additional fields in `params`:
index f98998fb8cdf828594fc83cf6289eefd63210f44..aae8f9626086209304a5d95c236e419b9234e6da 100644 (file)
@@ -221,6 +221,8 @@ If your instance is up and running, you can create your first user with administ
 doas -u akkoma env MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
 ```
 
+{! installation/frontends.include !}
+
 #### Further reading
 
 {! installation/further_reading.include !}
index f7a7d62391500d94d50544b6fe1e3380b153204e..639c9c7986e9910a62ffb17fa66feea8f586a20b 100644 (file)
@@ -212,6 +212,8 @@ If your instance is up and running, you can create your first user with administ
 sudo -Hu akkoma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
 ```
 
+{! installation/frontends.include !}
+
 #### Further reading
 
 {! installation/further_reading.include !}
index 40503db0ce46e5d9fe68c5bed0d076eae8282d39..139c789bc7767495d7f13271607887bff46bb7b3 100644 (file)
@@ -175,6 +175,8 @@ If your instance is up and running, you can create your first user with administ
 sudo -Hu akkoma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
 ```
 
+{! installation/frontends.include !}
+
 #### Further reading
 
 {! installation/further_reading.include !}
index 30d68d97fb444941ef44d15cec6937bb39741795..d8c7b3e743b4b795eb2cb35e16de847527caedce 100644 (file)
@@ -199,6 +199,8 @@ If your instance is up and running, you can create your first user with administ
 sudo -Hu akkoma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
 ```
 
+{! installation/frontends.include !}
+
 #### Further reading
 
 {! installation/further_reading.include !}
index be735a998476be222784c9faf0a3b8ef465ec219..53c029d275fc8e01b5882000edcfeaceeae91ab8 100644 (file)
@@ -206,6 +206,9 @@ If your instance is up and running, you can create your first user with administ
 ```shell
 sudo -Hu akkoma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
 ```
+
+{! installation/frontends.include !}
+
 ## Conclusion
 
 Restart nginx with `# service nginx restart` and you should be up and running.
diff --git a/docs/docs/installation/frontends.include b/docs/docs/installation/frontends.include
new file mode 100644 (file)
index 0000000..585be71
--- /dev/null
@@ -0,0 +1,25 @@
+#### Installing Frontends
+
+Once your backend server is functional, you'll also want to
+probably install frontends.
+
+These are no longer bundled with the distribution and need an extra
+command to install.
+
+For most installations, the following will suffice:
+
+=== "OTP" 
+    ```sh
+    ./bin/pleroma_ctl frontend install pleroma-fe --ref stable
+    # and also, if desired
+    ./bin/pleroma_ctl frontend install admin-fe --ref stable
+    ```
+
+=== "From Source"
+    ```sh
+    mix pleroma.frontend install pleroma-fe --ref stable
+    mix pleroma.frontend install admin-fe --ref stable
+    ```
+
+For more customised installations, refer to [Frontend Management](../../configuration/frontend_management)
+
index 4649b63bfca0e2b2aabeba8b0c0db9007e761b53..9450c9b3864ea54f9ec656cbb27d9f4048cdf409 100644 (file)
@@ -293,6 +293,8 @@ akkoma$ MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
 
 If you opted to allow sudo for the `akkoma` user but would like to remove the ability for greater security, now might be a good time to edit `/etc/sudoers` and/or change the groups the `akkoma` user belongs to. Be sure to restart the akkoma service afterwards to ensure it picks up on the changes.
 
+{! installation/frontends.include !}
+
 #### Further reading
 
 {! installation/further_reading.include !}
index 74b87e318d07e4c4d47175b05e1f106e3319c64d..d8ea0ea25ae65f22326fbc76941d40cf261475ed 100644 (file)
@@ -1,7 +1,5 @@
 # Migrating to Akkoma
 
-**Akkoma does not currently have a stable release, until 3.0, all builds should be considered "develop"**
-
 ## Why should you migrate?
 
 aside from actually responsive maintainer(s)? let's lookie here, we've got:
@@ -11,6 +9,8 @@ aside from actually responsive maintainer(s)? let's lookie here, we've got:
 - elasticsearch support (because pleroma search is GARBAGE)
 - latest develop pleroma-fe additions
 - local-only posting
+- automatic post translation
+- the mastodon frontend back in all its glory
 - probably more, this is like 3.5 years of IHBA additions finally compiled
 
 ## Actually migrating
@@ -43,14 +43,14 @@ This will just be setting the update URL - find your flavour from the [mapping o
 ```bash
 export FLAVOUR=[the flavour you found above]
 
-./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/develop/akkoma-$FLAVOUR.zip
+./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-$FLAVOUR.zip
 ./bin/pleroma_ctl migrate
 ```
 
 Then restart. When updating in the future, you canjust use
 
 ```bash
-./bin/pleroma_ctl update --branch develop
+./bin/pleroma_ctl update --branch stable
 ```
 
 ## Frontend changes
@@ -62,17 +62,18 @@ your upgrade path here depends on your setup
 
 You'll need to run a couple of commands,
 
-```bash
-# From source
-mix pleroma.frontend install pleroma-fe
-# you'll probably want this too
-mix pleroma.frontend install admin-fe
-
-# OTP
-./bin/pleroma_ctl frontend install pleroma-fe
-# you'll probably want this too
-./bin/pleroma_ctl frontend install admin-fe
-```
+=== "OTP"
+    ```sh
+    ./bin/pleroma_ctl frontend install pleroma-fe --ref stable
+    # and also, if desired
+    ./bin/pleroma_ctl frontend install admin-fe --ref stable
+    ```
+
+=== "From Source"
+    ```sh
+    mix pleroma.frontend install pleroma-fe --ref stable
+    mix pleroma.frontend install admin-fe --ref stable
+    ```
 
 ### I've run the mix task to install a frontend
 
index c00a32e343ca536892c5f3658c0590bab5865372..f13a3ee8947dc46c1b0d8d1f85a6c85828fed528 100644 (file)
@@ -202,6 +202,8 @@ incorrect timestamps. You should have ntpd running.
 
 * <https://catgirl.science>
 
+{! installation/frontends.include !}
+
 #### Further reading
 
 {! installation/further_reading.include !}
index c7e8cf0c09b3136eb31d2304448b737f7d19c863..581942f9902238edf05afd215f70f4e9a9f724b7 100644 (file)
@@ -250,6 +250,8 @@ If your instance is up and running, you can create your first user with administ
 LC_ALL=en_US.UTF-8 MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
 ```
 
+{! installation/frontends.include !}
+
 #### Further reading
 
 {! installation/further_reading.include !}
index 022716fec881d8a6d4239abca8f9a738e703a64d..329afe967de0ae3d1d967f6a2acc32f0b4615ecb 100644 (file)
@@ -306,6 +306,8 @@ su akkoma -s $SHELL -lc "./bin/pleroma_ctl user new joeuser joeuser@sld.tld --ad
 ```
 This will create an account withe the username of 'joeuser' with the email address of joeuser@sld.tld, and set that user's account as an admin. This will result in a link that you can paste into the browser, which logs you in and enables you to set the password.
 
+{! installation/frontends.include !}
+
 ## Further reading
 
 {! installation/further_reading.include !}
index 2e6b58c9e4ccc6460a94fdbdb507573bed36a385..ec6c30bcf99f224aabb9c8da8b099724a0813b15 100644 (file)
@@ -279,6 +279,7 @@ After that, run the `pleroma_ctl migrate` command as usual to perform database m
 
 As it currently stands, your OTP build will only be compatible for the specific RedHat distribution you've built it on. Fedora builds only work on Fedora, Centos builds only on Centos, RedHat builds only on RedHat. Secondly, for Fedora, they will also be bound to the specific Fedora release. This is because different releases of Fedora may have significant changes made in some of the required packages and libraries.
 
+{! installation/frontends.include !}
 
 {! installation/further_reading.include !}
 
diff --git a/docs/docs/installation/verifying_otp_releases.md b/docs/docs/installation/verifying_otp_releases.md
new file mode 100644 (file)
index 0000000..86dacfe
--- /dev/null
@@ -0,0 +1,66 @@
+# Verifying OTP release integrity
+
+All stable OTP releases are cryptographically signed, to allow
+you to verify the integrity if you choose to.
+
+Releases are signed with [Signify](https://man.openbsd.org/signify.1),
+with [the public key in the main repository](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/SIGNING_KEY.pub)
+
+Release URLs will always be of the form
+
+```
+https://akkoma-updates.s3-website.fr-par.scw.cloud/{branch}/akkoma-{flavour}.zip
+```
+
+Where branch is usually `stable` or `develop`, and `flavour` is
+the one [that you detect on install](../otp_en/#detecting-flavour).
+
+So, for an AMD64 stable install, your update URL will be
+
+```
+https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-amd64.zip
+```
+
+To verify the integrity of this file, we have two helper files
+
+```
+# Checksums
+https://akkoma-updates.s3-website.fr-par.scw.cloud/{branch}/akkoma-{flavour}.zip.sha256
+
+# Signify signature of the hashes
+https://akkoma-updates.s3-website.fr-par.scw.cloud/{branch}/akkoma-{flavour}.zip.sha256.sig
+```
+
+Thus, to upgrade manually, with integrity checking, consider the following script:
+
+```bash
+#!/bin/bash
+set -eo pipefail
+
+export FLAVOUR=amd64
+export BRANCH=stable
+
+# Fetch signing key
+curl --silent https://akkoma.dev/AkkomaGang/akkoma/raw/branch/$BRANCH/SIGNING_KEY.pub -o AKKOMA_SIGNING_KEY.pub
+
+# Download zip file and sig files
+wget -q https://akkoma-updates.s3-website.fr-par.scw.cloud/$BRANCH/akkoma-$FLAVOUR{.zip,.zip.sha256,.zip.sha256.sig}
+
+# Verify zip file's sha256 integrity
+sha256sum --check akkoma-$FLAVOUR.zip.sha256
+
+# Verify hash file's integrity
+# Signify might be under the `signify` command, depending on your distribution
+signify-openbsd -V -p AKKOMA_SIGNING_KEY.pub -m akkoma-$FLAVOUR.zip.sha256
+
+# We're good, use that URL
+echo "Update URL contents verified"
+echo "use"
+echo "./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/$BRANCH/akkoma-$FLAVOUR"
+echo "to update your instance"
+
+# Clean up
+rm akkoma-$FLAVOUR.zip
+rm akkoma-$FLAVOUR.zip.sha256
+rm akkoma-$FLAVOUR.zip.sha256.sig
+```
index d4a83c3cdb62747a39ca7c3efce0019bbacbb30d..27a31afcf4a684f8d87dfb8daac2abe6a3c7a7e3 100644 (file)
@@ -9,7 +9,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do
   import Ecto.Query
 
   import Pleroma.Search.Meilisearch,
-    only: [meili_post: 2, meili_put: 2, meili_get: 1, meili_delete!: 1]
+    only: [meili_put: 2, meili_get: 1, meili_delete!: 1]
 
   def run(["index"]) do
     start_pleroma()
@@ -27,7 +27,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do
     end
 
     {:ok, _} =
-      meili_post(
+      meili_put(
         "/indexes/objects/settings/ranking-rules",
         [
           "published:desc",
@@ -41,7 +41,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do
       )
 
     {:ok, _} =
-      meili_post(
+      meili_put(
         "/indexes/objects/settings/searchable-attributes",
         [
           "content"
@@ -91,7 +91,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do
             )
 
           with {:ok, res} <- result do
-            if not Map.has_key?(res, "uid") do
+            if not Map.has_key?(res, "indexUid") do
               IO.puts("\nFailed to index: #{inspect(result)}")
             end
           else
index 0bf39383674f3c385cd13e43f205fd4ac91a61cc..30409d93dc33b99bfb323f1d8f2157aec3f4c7f2 100644 (file)
@@ -8,6 +8,40 @@ defmodule Pleroma.Activity.HTML do
 
   @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
 
+  # We store a list of cache keys related to an activity in a
+  # separate cache, scrubber_management_cache. It has the same
+  # size as scrubber_cache (see application.ex). Every time we add
+  # a cache to scrubber_cache, we update scrubber_management_cache.
+  #
+  # The most recent write of a certain key in the management cache
+  # is the same as the most recent write of any record related to that
+  # key in the main cache.
+  # Assuming LRW ( https://hexdocs.pm/cachex/Cachex.Policy.LRW.html ),
+  # this means when the management cache is evicted by cachex, all
+  # related records in the main cache will also have been evicted.
+
+  defp get_cache_keys_for(activity_id) do
+    with {:ok, list} when is_list(list) <- @cachex.get(:scrubber_management_cache, activity_id) do
+      list
+    else
+      _ -> []
+    end
+  end
+
+  defp add_cache_key_for(activity_id, additional_key) do
+    current = get_cache_keys_for(activity_id)
+
+    unless additional_key in current do
+      @cachex.put(:scrubber_management_cache, activity_id, [additional_key | current])
+    end
+  end
+
+  def invalidate_cache_for(activity_id) do
+    keys = get_cache_keys_for(activity_id)
+    Enum.map(keys, &@cachex.del(:scrubber_cache, &1))
+    @cachex.del(:scrubber_management_cache, activity_id)
+  end
+
   def get_cached_scrubbed_html_for_activity(
         content,
         scrubbers,
@@ -19,6 +53,8 @@ defmodule Pleroma.Activity.HTML do
 
     @cachex.fetch!(:scrubber_cache, key, fn _key ->
       object = Object.normalize(activity, fetch: false)
+
+      add_cache_key_for(activity.id, key)
       HTML.ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback)
     end)
   end
diff --git a/lib/pleroma/akkoma/translators/deepl.ex b/lib/pleroma/akkoma/translators/deepl.ex
new file mode 100644 (file)
index 0000000..da6b8a5
--- /dev/null
@@ -0,0 +1,100 @@
+defmodule Pleroma.Akkoma.Translators.DeepL do
+  @behaviour Pleroma.Akkoma.Translator
+
+  alias Pleroma.HTTP
+  alias Pleroma.Config
+  require Logger
+
+  defp base_url(:free) do
+    "https://api-free.deepl.com/v2/"
+  end
+
+  defp base_url(:pro) do
+    "https://api.deepl.com/v2/"
+  end
+
+  defp api_key do
+    Config.get([:deepl, :api_key])
+  end
+
+  defp tier do
+    Config.get([:deepl, :tier])
+  end
+
+  @impl Pleroma.Akkoma.Translator
+  def languages do
+    with {:ok, %{status: 200} = source_response} <- do_languages("source"),
+         {:ok, %{status: 200} = dest_response} <- do_languages("target"),
+         {:ok, source_body} <- Jason.decode(source_response.body),
+         {:ok, dest_body} <- Jason.decode(dest_response.body) do
+      source_resp =
+        Enum.map(source_body, fn %{"language" => code, "name" => name} ->
+          %{code: code, name: name}
+        end)
+
+      dest_resp =
+        Enum.map(dest_body, fn %{"language" => code, "name" => name} ->
+          %{code: code, name: name}
+        end)
+
+      {:ok, source_resp, dest_resp}
+    else
+      {:ok, %{status: status} = response} ->
+        Logger.warning("DeepL: Request rejected: #{inspect(response)}")
+        {:error, "DeepL request failed (code #{status})"}
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  @impl Pleroma.Akkoma.Translator
+  def translate(string, from_language, to_language) do
+    with {:ok, %{status: 200} = response} <-
+           do_request(api_key(), tier(), string, from_language, to_language),
+         {:ok, body} <- Jason.decode(response.body) do
+      %{"translations" => [%{"text" => translated, "detected_source_language" => detected}]} =
+        body
+
+      {:ok, detected, translated}
+    else
+      {:ok, %{status: status} = response} ->
+        Logger.warning("DeepL: Request rejected: #{inspect(response)}")
+        {:error, "DeepL request failed (code #{status})"}
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  defp do_request(api_key, tier, string, from_language, to_language) do
+    HTTP.post(
+      base_url(tier) <> "translate",
+      URI.encode_query(
+        %{
+          text: string,
+          target_lang: to_language,
+          tag_handling: "html"
+        }
+        |> maybe_add_source(from_language),
+        :rfc3986
+      ),
+      [
+        {"authorization", "DeepL-Auth-Key #{api_key}"},
+        {"content-type", "application/x-www-form-urlencoded"}
+      ]
+    )
+  end
+
+  defp maybe_add_source(opts, nil), do: opts
+  defp maybe_add_source(opts, lang), do: Map.put(opts, :source_lang, lang)
+
+  defp do_languages(type) do
+    HTTP.get(
+      base_url(tier()) <> "languages?type=#{type}",
+      [
+        {"authorization", "DeepL-Auth-Key #{api_key()}"}
+      ]
+    )
+  end
+end
diff --git a/lib/pleroma/akkoma/translators/libre_translate.ex b/lib/pleroma/akkoma/translators/libre_translate.ex
new file mode 100644 (file)
index 0000000..3a8d9d8
--- /dev/null
@@ -0,0 +1,82 @@
+defmodule Pleroma.Akkoma.Translators.LibreTranslate do
+  @behaviour Pleroma.Akkoma.Translator
+
+  alias Pleroma.Config
+  alias Pleroma.HTTP
+  require Logger
+
+  defp api_key do
+    Config.get([:libre_translate, :api_key])
+  end
+
+  defp url do
+    Config.get([:libre_translate, :url])
+  end
+
+  @impl Pleroma.Akkoma.Translator
+  def languages do
+    with {:ok, %{status: 200} = response} <- do_languages(),
+         {:ok, body} <- Jason.decode(response.body) do
+      resp = Enum.map(body, fn %{"code" => code, "name" => name} -> %{code: code, name: name} end)
+      # No separate source/dest
+      {:ok, resp, resp}
+    else
+      {:ok, %{status: status} = response} ->
+        Logger.warning("LibreTranslate: Request rejected: #{inspect(response)}")
+        {:error, "LibreTranslate request failed (code #{status})"}
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  @impl Pleroma.Akkoma.Translator
+  def translate(string, from_language, to_language) do
+    with {:ok, %{status: 200} = response} <- do_request(string, from_language, to_language),
+         {:ok, body} <- Jason.decode(response.body) do
+      %{"translatedText" => translated} = body
+
+      detected =
+        if Map.has_key?(body, "detectedLanguage") do
+          get_in(body, ["detectedLanguage", "language"])
+        else
+          from_language
+        end
+
+      {:ok, detected, translated}
+    else
+      {:ok, %{status: status} = response} ->
+        Logger.warning("libre_translate: request failed, #{inspect(response)}")
+        {:error, "libre_translate: request failed (code #{status})"}
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  defp do_request(string, from_language, to_language) do
+    url = URI.parse(url())
+    url = %{url | path: "/translate"}
+
+    HTTP.post(
+      to_string(url),
+      Jason.encode!(%{
+        q: string,
+        source: if(is_nil(from_language), do: "auto", else: from_language),
+        target: to_language,
+        format: "html",
+        api_key: api_key()
+      }),
+      [
+        {"content-type", "application/json"}
+      ]
+    )
+  end
+
+  defp do_languages() do
+    url = URI.parse(url())
+    url = %{url | path: "/languages"}
+
+    HTTP.get(to_string(url))
+  end
+end
diff --git a/lib/pleroma/akkoma/translators/translator.ex b/lib/pleroma/akkoma/translators/translator.ex
new file mode 100644 (file)
index 0000000..93fbeb3
--- /dev/null
@@ -0,0 +1,8 @@
+defmodule Pleroma.Akkoma.Translator do
+  @callback translate(String.t(), String.t() | nil, String.t()) ::
+              {:ok, String.t(), String.t()} | {:error, any()}
+  @callback languages() ::
+              {:ok, [%{name: String.t(), code: String.t()}],
+               [%{name: String.t(), code: String.t()}]}
+              | {:error, any()}
+end
index cb619232fe350a14e9eb7c9b93d0ce44195da4b3..adccd7c5dfbc087fbec54e90b287b031c597c03b 100644 (file)
@@ -63,7 +63,8 @@ defmodule Pleroma.Application do
         Pleroma.Repo,
         Config.TransferTask,
         Pleroma.Emoji,
-        Pleroma.Web.Plugs.RateLimiter.Supervisor
+        Pleroma.Web.Plugs.RateLimiter.Supervisor,
+        {Task.Supervisor, name: Pleroma.TaskSupervisor}
       ] ++
         cachex_children() ++
         http_children() ++
@@ -149,11 +150,13 @@ defmodule Pleroma.Application do
       build_cachex("object", default_ttl: 25_000, ttl_interval: 1000, limit: 2500),
       build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000),
       build_cachex("scrubber", limit: 2500),
+      build_cachex("scrubber_management", limit: 2500),
       build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500),
       build_cachex("web_resp", limit: 2500),
       build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
       build_cachex("failed_proxy_url", limit: 2500),
-      build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000)
+      build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000),
+      build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500)
     ]
   end
 
index 0c81f0b56cbb3531d48be834747a15b0be66a9a1..ab69f4b848e05f8062f9b71e286b6589b0a4d865 100644 (file)
@@ -11,10 +11,7 @@ defmodule Akkoma.Collections.Fetcher do
   alias Pleroma.Config
   require Logger
 
-  def fetch_collection_by_ap_id(ap_id) when is_binary(ap_id) do
-    fetch_collection(ap_id)
-  end
-
+  @spec fetch_collection(String.t() | map()) :: {:ok, [Pleroma.Object.t()]} | {:error, any()}
   def fetch_collection(ap_id) when is_binary(ap_id) do
     with {:ok, page} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do
       {:ok, objects_from_collection(page)}
@@ -26,7 +23,7 @@ defmodule Akkoma.Collections.Fetcher do
   end
 
   def fetch_collection(%{"type" => type} = page)
-      when type in ["Collection", "OrderedCollection"] do
+      when type in ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"] do
     {:ok, objects_from_collection(page)}
   end
 
@@ -38,12 +35,13 @@ defmodule Akkoma.Collections.Fetcher do
        when is_list(items) and type in ["Collection", "CollectionPage"],
        do: items
 
-  defp objects_from_collection(%{"type" => "OrderedCollection", "orderedItems" => items})
-       when is_list(items),
-       do: items
+  defp objects_from_collection(%{"type" => type, "orderedItems" => items} = page)
+       when is_list(items) and type in ["OrderedCollection", "OrderedCollectionPage"],
+       do: maybe_next_page(page, items)
 
-  defp objects_from_collection(%{"type" => "Collection", "items" => items}) when is_list(items),
-    do: items
+  defp objects_from_collection(%{"type" => type, "items" => items} = page)
+       when is_list(items) and type in ["Collection", "CollectionPage"],
+       do: maybe_next_page(page, items)
 
   defp objects_from_collection(%{"type" => type, "first" => first})
        when is_binary(first) and type in ["Collection", "OrderedCollection"] do
@@ -55,17 +53,27 @@ defmodule Akkoma.Collections.Fetcher do
     fetch_page_items(id)
   end
 
+  defp objects_from_collection(_page), do: []
+
   defp fetch_page_items(id, items \\ []) do
     if Enum.count(items) >= Config.get([:activitypub, :max_collection_objects]) do
       items
     else
-      {:ok, page} = Fetcher.fetch_and_contain_remote_object_from_id(id)
-      objects = items_in_page(page)
+      with {:ok, page} <- Fetcher.fetch_and_contain_remote_object_from_id(id) do
+        objects = items_in_page(page)
 
-      if Enum.count(objects) > 0 do
-        maybe_next_page(page, items ++ objects)
+        if Enum.count(objects) > 0 do
+          maybe_next_page(page, items ++ objects)
+        else
+          items
+        end
       else
-        items
+        {:error, "Object has been deleted"} ->
+          items
+
+        {:error, error} ->
+          Logger.error("Could not fetch page #{id} - #{inspect(error)}")
+          {:error, error}
       end
     end
   end
index 6a3184e6c03511ee7f96c6d0af95bcb7aaf862bb..81dc847cf8e5771cb2d96b1e6c7090d72b61a5c9 100644 (file)
@@ -38,7 +38,6 @@ defmodule Pleroma.Config.TransferTask do
   def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do
     with {_, true} <- {:configurable, Config.get(:configurable_from_database)} do
       # We need to restart applications for loaded settings take effect
-
       {logger, other} =
         (Repo.all(ConfigDB) ++ deleted_settings)
         |> Enum.map(&merge_with_default/1)
@@ -85,7 +84,12 @@ defmodule Pleroma.Config.TransferTask do
   end
 
   defp merge_with_default(%{group: group, key: key, value: value} = setting) do
-    default = Config.Holder.default_config(group, key)
+    default =
+      if group == :pleroma do
+        Config.get([key], Config.Holder.default_config(group, key))
+      else
+        Config.Holder.default_config(group, key)
+      end
 
     merged =
       cond do
index bf92f65cb170d1267fc5242765fcc521bde1861b..7343ef8c3be1a8071c262498b3adf0f8355f7a8c 100644 (file)
@@ -27,4 +27,40 @@ defmodule Pleroma.Constants do
     do:
       ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css)
   )
+
+  const(status_updatable_fields,
+    do: [
+      "source",
+      "tag",
+      "updated",
+      "emoji",
+      "content",
+      "summary",
+      "sensitive",
+      "attachment",
+      "generator"
+    ]
+  )
+
+  const(updatable_object_types,
+    do: [
+      "Note",
+      "Question",
+      "Audio",
+      "Video",
+      "Event",
+      "Article",
+      "Page"
+    ]
+  )
+
+  const(actor_types,
+    do: [
+      "Application",
+      "Group",
+      "Organization",
+      "Person",
+      "Service"
+    ]
+  )
 end
index 24eafda4143dc06e071acc1080fc60b5e1e0c17f..dbe9abe8d54775b188afc5af2b962643fa7dc571 100644 (file)
@@ -188,6 +188,11 @@ defmodule Pleroma.Emoji do
 
   def emoji_url(_), do: nil
 
+  def emoji_name_with_instance(name, url) do
+    url = url |> URI.parse() |> Map.get(:host)
+    "#{name}@#{url}"
+  end
+
   emoji_qualification_map =
     emojis
     |> Enum.filter(&String.contains?(&1, "\uFE0F"))
index 13e4c815856294fe585ac91ec22199de4095eeb0..37765da4d7d04ca7b6a0bb705d0871b0078cc3e1 100644 (file)
@@ -9,6 +9,7 @@ defmodule Pleroma.Helpers.AuthHelper do
   import Plug.Conn
 
   @oauth_token_session_key :oauth_token
+  @oauth_user_session_key :oauth_user
 
   @doc """
   Skips OAuth permissions (scopes) checks, assigns nil `:token`.
@@ -43,4 +44,16 @@ defmodule Pleroma.Helpers.AuthHelper do
   def delete_session_token(%Conn{} = conn) do
     delete_session(conn, @oauth_token_session_key)
   end
+
+  def put_session_user(%Conn{} = conn, user) do
+    put_session(conn, @oauth_user_session_key, user)
+  end
+
+  def delete_session_user(%Conn{} = conn) do
+    delete_session(conn, @oauth_user_session_key)
+  end
+
+  def get_session_user(%Conn{} = conn) do
+    get_session(conn, @oauth_user_session_key)
+  end
 end
index d8878338ee5859b90f9a1ca843a4afc4871e4d69..593448713a59ff6e7aeb1a2f48c75243991ecbce 100644 (file)
@@ -384,7 +384,7 @@ defmodule Pleroma.Notification do
   end
 
   def create_notifications(%Activity{data: %{"type" => type}} = activity, options)
-      when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag"] do
+      when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag", "Update"] do
     do_create_notifications(activity, options)
   end
 
@@ -438,6 +438,9 @@ defmodule Pleroma.Notification do
         activity
         |> type_from_activity_object()
 
+      "Update" ->
+        "update"
+
       t ->
         raise "No notification type for activity type #{t}"
     end
@@ -503,7 +506,16 @@ defmodule Pleroma.Notification do
   def get_notified_from_activity(activity, local_only \\ true)
 
   def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
-      when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact", "Flag"] do
+      when type in [
+             "Create",
+             "Like",
+             "Announce",
+             "Follow",
+             "Move",
+             "EmojiReact",
+             "Flag",
+             "Update"
+           ] do
     potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
 
     potential_receivers =
@@ -543,6 +555,21 @@ defmodule Pleroma.Notification do
     (User.all_superusers() |> Enum.map(fn user -> user.ap_id end)) -- [actor]
   end
 
+  # Update activity: notify all who repeated this
+  def get_potential_receiver_ap_ids(%{data: %{"type" => "Update", "actor" => actor}} = activity) do
+    with %Object{data: %{"id" => object_id}} <- Object.normalize(activity, fetch: false) do
+      repeaters =
+        Activity.Queries.by_type("Announce")
+        |> Activity.Queries.by_object_id(object_id)
+        |> Activity.with_joined_user_actor()
+        |> where([a, u], u.local)
+        |> select([a, u], u.ap_id)
+        |> Repo.all()
+
+      repeaters -- [actor]
+    end
+  end
+
   def get_potential_receiver_ap_ids(activity) do
     []
     |> Utils.maybe_notify_to_recipients(activity)
index 4ca67f0fda211e98b5e5f305c8ccf88f97cac2bc..8ec28345f3c29bd4321a8edfedc4bff9402d3fa8 100644 (file)
@@ -26,8 +26,42 @@ defmodule Pleroma.Object.Fetcher do
   end
 
   defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do
+    has_history? = fn
+      %{"formerRepresentations" => %{"orderedItems" => list}} when is_list(list) -> true
+      _ -> false
+    end
+
     internal_fields = Map.take(old_data, Pleroma.Constants.object_internal_fields())
 
+    remote_history_exists? = has_history?.(new_data)
+
+    # If the remote history exists, we treat that as the only source of truth.
+    new_data =
+      if has_history?.(old_data) and not remote_history_exists? do
+        Map.put(new_data, "formerRepresentations", old_data["formerRepresentations"])
+      else
+        new_data
+      end
+
+    # If the remote does not have history information, we need to manage it ourselves
+    new_data =
+      if not remote_history_exists? do
+        changed? =
+          Pleroma.Constants.status_updatable_fields()
+          |> Enum.any?(fn field -> Map.get(old_data, field) != Map.get(new_data, field) end)
+
+        %{updated_object: updated_object} =
+          new_data
+          |> Object.Updater.maybe_update_history(old_data,
+            updated: changed?,
+            use_history_in_new_object?: false
+          )
+
+        updated_object
+      else
+        new_data
+      end
+
     Map.merge(new_data, internal_fields)
   end
 
diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex
new file mode 100644 (file)
index 0000000..ab38d3e
--- /dev/null
@@ -0,0 +1,240 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Object.Updater do
+  require Pleroma.Constants
+
+  def update_content_fields(orig_object_data, updated_object) do
+    Pleroma.Constants.status_updatable_fields()
+    |> Enum.reduce(
+      %{data: orig_object_data, updated: false},
+      fn field, %{data: data, updated: updated} ->
+        updated =
+          updated or
+            (field != "updated" and
+               Map.get(updated_object, field) != Map.get(orig_object_data, field))
+
+        data =
+          if Map.has_key?(updated_object, field) do
+            Map.put(data, field, updated_object[field])
+          else
+            Map.drop(data, [field])
+          end
+
+        %{data: data, updated: updated}
+      end
+    )
+  end
+
+  def maybe_history(object) do
+    with history <- Map.get(object, "formerRepresentations"),
+         true <- is_map(history),
+         "OrderedCollection" <- Map.get(history, "type"),
+         true <- is_list(Map.get(history, "orderedItems")),
+         true <- is_integer(Map.get(history, "totalItems")) do
+      history
+    else
+      _ -> nil
+    end
+  end
+
+  def history_for(object) do
+    with history when not is_nil(history) <- maybe_history(object) do
+      history
+    else
+      _ -> history_skeleton()
+    end
+  end
+
+  defp history_skeleton do
+    %{
+      "type" => "OrderedCollection",
+      "totalItems" => 0,
+      "orderedItems" => []
+    }
+  end
+
+  def maybe_update_history(
+        updated_object,
+        orig_object_data,
+        opts
+      ) do
+    updated = opts[:updated]
+    use_history_in_new_object? = opts[:use_history_in_new_object?]
+
+    if not updated do
+      %{updated_object: updated_object, used_history_in_new_object?: false}
+    else
+      # Put edit history
+      # Note that we may have got the edit history by first fetching the object
+      {new_history, used_history_in_new_object?} =
+        with true <- use_history_in_new_object?,
+             updated_history when not is_nil(updated_history) <- maybe_history(opts[:new_data]) do
+          {updated_history, true}
+        else
+          _ ->
+            history = history_for(orig_object_data)
+
+            latest_history_item =
+              orig_object_data
+              |> Map.drop(["id", "formerRepresentations"])
+
+            updated_history =
+              history
+              |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]])
+              |> Map.put("totalItems", history["totalItems"] + 1)
+
+            {updated_history, false}
+        end
+
+      updated_object =
+        updated_object
+        |> Map.put("formerRepresentations", new_history)
+
+      %{updated_object: updated_object, used_history_in_new_object?: used_history_in_new_object?}
+    end
+  end
+
+  defp maybe_update_poll(to_be_updated, updated_object) do
+    choice_key = fn data ->
+      if Map.has_key?(data, "anyOf"), do: "anyOf", else: "oneOf"
+    end
+
+    with true <- to_be_updated["type"] == "Question",
+         key <- choice_key.(updated_object),
+         true <- key == choice_key.(to_be_updated),
+         orig_choices <- to_be_updated[key] |> Enum.map(&Map.drop(&1, ["replies"])),
+         new_choices <- updated_object[key] |> Enum.map(&Map.drop(&1, ["replies"])),
+         true <- orig_choices == new_choices do
+      # Choices are the same, but counts are different
+      to_be_updated
+      |> Map.put(key, updated_object[key])
+    else
+      # Choices (or vote type) have changed, do not allow this
+      _ -> to_be_updated
+    end
+  end
+
+  # This calculates the data to be sent as the object of an Update.
+  # new_data's formerRepresentations is not considered.
+  # formerRepresentations is added to the returned data.
+  def make_update_object_data(original_data, new_data, date) do
+    %{data: updated_data, updated: updated} =
+      original_data
+      |> update_content_fields(new_data)
+
+    if not updated do
+      updated_data
+    else
+      %{updated_object: updated_data} =
+        updated_data
+        |> maybe_update_history(original_data, updated: updated, use_history_in_new_object?: false)
+
+      updated_data
+      |> Map.put("updated", date)
+    end
+  end
+
+  # This calculates the data of the new Object from an Update.
+  # new_data's formerRepresentations is considered.
+  def make_new_object_data_from_update_object(original_data, new_data) do
+    update_is_reasonable =
+      with {_, updated} when not is_nil(updated) <- {:cur_updated, new_data["updated"]},
+           {_, {:ok, updated_time, _}} <- {:cur_updated, DateTime.from_iso8601(updated)},
+           {_, last_updated} when not is_nil(last_updated) <-
+             {:last_updated, original_data["updated"] || original_data["published"]},
+           {_, {:ok, last_updated_time, _}} <-
+             {:last_updated, DateTime.from_iso8601(last_updated)},
+           :gt <- DateTime.compare(updated_time, last_updated_time) do
+        :update_everything
+      else
+        # only allow poll updates
+        {:cur_updated, _} -> :no_content_update
+        :eq -> :no_content_update
+        # allow all updates
+        {:last_updated, _} -> :update_everything
+        # allow no updates
+        _ -> false
+      end
+
+    %{
+      updated_object: updated_data,
+      used_history_in_new_object?: used_history_in_new_object?,
+      updated: updated
+    } =
+      if update_is_reasonable == :update_everything do
+        %{data: updated_data, updated: updated} =
+          original_data
+          |> update_content_fields(new_data)
+
+        updated_data
+        |> maybe_update_history(original_data,
+          updated: updated,
+          use_history_in_new_object?: true,
+          new_data: new_data
+        )
+        |> Map.put(:updated, updated)
+      else
+        %{
+          updated_object: original_data,
+          used_history_in_new_object?: false,
+          updated: false
+        }
+      end
+
+    updated_data =
+      if update_is_reasonable != false do
+        updated_data
+        |> maybe_update_poll(new_data)
+      else
+        updated_data
+      end
+
+    %{
+      updated_data: updated_data,
+      updated: updated,
+      used_history_in_new_object?: used_history_in_new_object?
+    }
+  end
+
+  def for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do
+    new_items =
+      Enum.map(items, fun)
+      |> Enum.reduce_while(
+        {:ok, []},
+        fn
+          {:ok, item}, {:ok, acc} -> {:cont, {:ok, acc ++ [item]}}
+          e, _acc -> {:halt, e}
+        end
+      )
+
+    case new_items do
+      {:ok, items} -> {:ok, Map.put(history, "orderedItems", items)}
+      e -> e
+    end
+  end
+
+  def for_each_history_item(history, _, _) do
+    {:ok, history}
+  end
+
+  def do_with_history(object, fun) do
+    with history <- object["formerRepresentations"],
+         object <- Map.drop(object, ["formerRepresentations"]),
+         {_, {:ok, object}} <- {:main_body, fun.(object)},
+         {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do
+      object =
+        if history do
+          Map.put(object, "formerRepresentations", history)
+        else
+          object
+        end
+
+      {:ok, object}
+    else
+      {:main_body, e} -> e
+      {:history_items, e} -> e
+    end
+  end
+end
index 1e06aafe44dfa94730f0723798ab4142c5352fc7..e43eef07095f24730153145715678597c9da540b 100644 (file)
@@ -25,7 +25,7 @@ defmodule Pleroma.ReleaseTasks do
         module = Module.split(module)
 
         match?(["Mix", "Tasks", "Pleroma" | _], module) and
-          String.downcase(List.last(module)) == task
+          task_match?(module, task)
       end)
 
     if module do
@@ -35,6 +35,13 @@ defmodule Pleroma.ReleaseTasks do
     end
   end
 
+  defp task_match?(["Mix", "Tasks", "Pleroma" | module_path], task) do
+    module_path
+    |> Enum.join(".")
+    |> String.downcase()
+    |> String.equivalent?(String.downcase(task))
+  end
+
   def migrate(args) do
     Mix.Tasks.Pleroma.Ecto.Migrate.run(args)
   end
index 7c7ca82c8825e19a39b5807ec51afe9cb3d5b5e5..16b01101acced74ffb55d96f652aaa5b5e2fe6f3 100644 (file)
@@ -23,7 +23,7 @@ defmodule Pleroma.Search.Elasticsearch do
         timeout: "5s",
         sort: [
           "_score",
-          %{_timestamp: %{order: "desc", format: "basic_date_time"}}
+          %{"_timestamp" => %{order: "desc", format: "basic_date_time"}}
         ],
         query: %{
           bool: %{
@@ -62,8 +62,12 @@ defmodule Pleroma.Search.Elasticsearch do
       Task.async(fn ->
         q = es_query(:activity, parsed_query, offset, limit)
 
-        Pleroma.Search.Elasticsearch.Store.search(:activities, q)
-        |> Enum.filter(fn x -> Visibility.visible_for_user?(x, user) end)
+        :activities
+        |> Pleroma.Search.Elasticsearch.Store.search(q)
+        |> Enum.filter(fn x ->
+          x.data["type"] == "Create" && x.object.data["type"] == "Note" &&
+            Visibility.visible_for_user?(x, user)
+        end)
       end)
 
     activity_results = Task.await(activity_task)
index 895b76d7f85e12f9af6acbf47d07d4c24c524b78..3b7bbb838eff61df076af22178abb7df12b84d7c 100644 (file)
@@ -42,7 +42,6 @@ defmodule Pleroma.Search.Elasticsearch.Store do
       results
       |> Enum.map(fn result -> result["_id"] end)
       |> Pleroma.Activity.all_by_ids_with_object()
-      |> Enum.sort(&(&1.inserted_at >= &2.inserted_at))
     else
       e ->
         Logger.error(e)
index 17822dc5eb65959e5ad753f9550d0a119de058d8..9bf8e03df49aab6c7b1fb64952ee6befd10168a0 100644 (file)
@@ -36,6 +36,7 @@ defmodule Pleroma.Upload do
   alias Ecto.UUID
   alias Pleroma.Config
   alias Pleroma.Maps
+  alias Pleroma.Web.ActivityPub.Utils
   require Logger
 
   @type source ::
@@ -88,6 +89,7 @@ defmodule Pleroma.Upload do
          {:ok, url_spec} <- Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload) do
       {:ok,
        %{
+         "id" => Utils.generate_object_id(),
          "type" => opts.activity_type,
          "mediaType" => upload.content_type,
          "url" => [
index 03e72be58443f9f7e3822cc23a1d84499ac7d7d4..dcdc7085fdaebc45eb36f4d279100ff33cce59a3 100644 (file)
@@ -194,7 +194,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
   def notify_and_stream(activity) do
     Notification.create_notifications(activity)
 
-    conversation = create_or_bump_conversation(activity, activity.actor)
+    original_activity =
+      case activity do
+        %{data: %{"type" => "Update"}, object: %{data: %{"id" => id}}} ->
+          Activity.get_create_by_object_ap_id_with_object(id)
+
+        _ ->
+          activity
+      end
+
+    conversation = create_or_bump_conversation(original_activity, original_activity.actor)
     participations = get_participations(conversation)
     stream_out(activity)
     stream_out_participations(participations)
@@ -260,7 +269,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
   @impl true
   def stream_out(%Activity{data: %{"type" => data_type}} = activity)
-      when data_type in ["Create", "Announce", "Delete"] do
+      when data_type in ["Create", "Announce", "Delete", "Update"] do
     activity
     |> Topics.get_activity_topics()
     |> Streamer.stream(activity)
@@ -331,9 +340,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
   defp do_unfollow(follower, followed, activity_id, local) when local == true do
     with %Activity{} = follow_activity <- fetch_latest_follow(follower, followed),
-         {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
          unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
          {:ok, activity} <- insert(unfollow_data, local),
+         {:ok, _activity} <- Repo.delete(follow_activity),
          _ <- notify_and_stream(activity),
          :ok <- maybe_federate(activity) do
       {:ok, activity}
@@ -349,7 +358,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     with %Activity{} = follow_activity <- fetch_latest_follow(follower, followed),
          {:ok, _activity} <- Repo.delete(follow_activity),
          unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
-         unfollow_activity <- remote_unfollow_data(unfollow_data),
+         unfollow_activity <- make_unfollow_activity(unfollow_data, false),
          _ <- notify_and_stream(unfollow_activity) do
       {:ok, unfollow_activity}
     else
@@ -358,12 +367,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     end
   end
 
-  defp remote_unfollow_data(data) do
+  defp make_unfollow_activity(data, local) do
     {recipients, _, _} = get_recipients(data)
 
     %Activity{
       data: data,
-      local: false,
+      local: local,
       actor: data["actor"],
       recipients: recipients
     }
index 97ceaf08ecfa8ec008d753948bd0a502903162e4..6d39ad3a858bec9394acb0c9baac3d44f29a860e 100644 (file)
@@ -55,37 +55,84 @@ defmodule Pleroma.Web.ActivityPub.Builder do
     {:ok, data, []}
   end
 
+  defp unicode_emoji_react(_object, data, emoji) do
+    data
+    |> Map.put("content", emoji)
+    |> Map.put("type", "EmojiReact")
+  end
+
+  defp add_emoji_content(data, emoji, url) do
+    data
+    |> Map.put("content", Emoji.maybe_quote(emoji))
+    |> Map.put("type", "EmojiReact")
+    |> Map.put("tag", [
+      %{}
+      |> Map.put("id", url)
+      |> Map.put("type", "Emoji")
+      |> Map.put("name", Emoji.maybe_quote(emoji))
+      |> Map.put(
+        "icon",
+        %{}
+        |> Map.put("type", "Image")
+        |> Map.put("url", url)
+      )
+    ])
+  end
+
+  defp remote_custom_emoji_react(
+         %{data: %{"reactions" => existing_reactions}},
+         data,
+         emoji
+       ) do
+    [emoji_code, instance] = String.split(Emoji.stripped_name(emoji), "@")
+
+    matching_reaction =
+      Enum.find(
+        existing_reactions,
+        fn [name, _, url] ->
+          url = URI.parse(url)
+          url.host == instance && name == emoji_code
+        end
+      )
+
+    if matching_reaction do
+      [name, _, url] = matching_reaction
+      add_emoji_content(data, name, url)
+    else
+      {:error, "Could not react"}
+    end
+  end
+
+  defp remote_custom_emoji_react(_object, _data, _emoji) do
+    {:error, "Could not react"}
+  end
+
+  defp local_custom_emoji_react(data, emoji) do
+    with %{} = emojo <- Emoji.get(emoji) do
+      path = emojo |> Map.get(:file)
+      url = "#{Endpoint.url()}#{path}"
+      add_emoji_content(data, emojo.code, url)
+    else
+      _ -> {:error, "Emoji does not exist"}
+    end
+  end
+
+  defp custom_emoji_react(object, data, emoji) do
+    if String.contains?(emoji, "@") do
+      remote_custom_emoji_react(object, data, emoji)
+    else
+      local_custom_emoji_react(data, emoji)
+    end
+  end
+
   @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()}
   def emoji_react(actor, object, emoji) do
     with {:ok, data, meta} <- object_action(actor, object) do
       data =
         if Emoji.is_unicode_emoji?(emoji) do
-          data
-          |> Map.put("content", emoji)
-          |> Map.put("type", "EmojiReact")
+          unicode_emoji_react(object, data, emoji)
         else
-          with %{} = emojo <- Emoji.get(emoji) do
-            path = emojo |> Map.get(:file)
-            url = "#{Endpoint.url()}#{path}"
-
-            data
-            |> Map.put("content", emoji)
-            |> Map.put("type", "EmojiReact")
-            |> Map.put("tag", [
-              %{}
-              |> Map.put("id", url)
-              |> Map.put("type", "Emoji")
-              |> Map.put("name", emojo.code)
-              |> Map.put(
-                "icon",
-                %{}
-                |> Map.put("type", "Image")
-                |> Map.put("url", url)
-              )
-            ])
-          else
-            _ -> {:error, "Emoji does not exist"}
-          end
+          custom_emoji_react(object, data, emoji)
         end
 
       {:ok, data, meta}
@@ -231,10 +278,16 @@ defmodule Pleroma.Web.ActivityPub.Builder do
     end
   end
 
-  # Retricted to user updates for now, always public
   @spec update(User.t(), Object.t()) :: {:ok, map(), keyword()}
   def update(actor, object) do
-    to = [Pleroma.Constants.as_public(), actor.follower_address]
+    {to, cc} =
+      if object["type"] in Pleroma.Constants.actor_types() do
+        # User updates, always public
+        {[Pleroma.Constants.as_public(), actor.follower_address], []}
+      else
+        # Status updates, follow the recipients in the object
+        {object["to"] || [], object["cc"] || []}
+      end
 
     {:ok,
      %{
@@ -242,7 +295,8 @@ defmodule Pleroma.Web.ActivityPub.Builder do
        "type" => "Update",
        "actor" => actor.ap_id,
        "object" => object,
-       "to" => to
+       "to" => to,
+       "cc" => cc
      }, []}
   end
 
index bd6f6777f3aabacbfc30ad303320b38b8da1e5c0..4df226e80eb09de8754e8bb55e69c6a1f3b6362f 100644 (file)
@@ -41,6 +41,16 @@ defmodule Pleroma.Web.ActivityPub.MRF do
           suggestions: [
             "exclusion.com"
           ]
+        },
+        %{
+          key: :transparency_obfuscate_domains,
+          label: "MRF domain obfuscation",
+          type: {:list, :string},
+          description:
+            "Obfuscate domains in MRF transparency. This is useful if the domain you're blocking contains words you don't want displayed, but still want to disclose the MRF settings.",
+          suggestions: [
+            "badword.com"
+          ]
         }
       ]
     }
@@ -53,10 +63,53 @@ defmodule Pleroma.Web.ActivityPub.MRF do
 
   @required_description_keys [:key, :related_policy]
 
+  def filter_one(policy, message) do
+    should_plug_history? =
+      if function_exported?(policy, :history_awareness, 0) do
+        policy.history_awareness()
+      else
+        :manual
+      end
+      |> Kernel.==(:auto)
+
+    if not should_plug_history? do
+      policy.filter(message)
+    else
+      main_result = policy.filter(message)
+
+      with {_, {:ok, main_message}} <- {:main, main_result},
+           {_,
+            %{
+              "formerRepresentations" => %{
+                "orderedItems" => [_ | _]
+              }
+            }} = {_, object} <- {:object, message["object"]},
+           {_, {:ok, new_history}} <-
+             {:history,
+              Pleroma.Object.Updater.for_each_history_item(
+                object["formerRepresentations"],
+                object,
+                fn item ->
+                  with {:ok, filtered} <- policy.filter(Map.put(message, "object", item)) do
+                    {:ok, filtered["object"]}
+                  else
+                    e -> e
+                  end
+                end
+              )} do
+        {:ok, put_in(main_message, ["object", "formerRepresentations"], new_history)}
+      else
+        {:main, _} -> main_result
+        {:object, _} -> main_result
+        {:history, e} -> e
+      end
+    end
+  end
+
   def filter(policies, %{} = message) do
     policies
     |> Enum.reduce({:ok, message}, fn
-      policy, {:ok, message} -> policy.filter(message)
+      policy, {:ok, message} -> filter_one(policy, message)
       _, error -> error
     end)
   end
@@ -85,7 +138,11 @@ defmodule Pleroma.Web.ActivityPub.MRF do
   def get_policies do
     Pleroma.Config.get([:mrf, :policies], [])
     |> get_policies()
-    |> Enum.concat([Pleroma.Web.ActivityPub.MRF.HashtagPolicy])
+    |> Enum.concat([
+      Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
+      Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy
+    ])
+    |> Enum.uniq()
   end
 
   defp get_policies(policy) when is_atom(policy), do: [policy]
index cdf17fd28c001aa6a3ff3d464e6033503e582194..ba7c8400bf8a8f3dfa877b2d79e126abb6ce2a80 100644 (file)
@@ -9,6 +9,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do
 
   require Logger
 
+  @impl true
+  def history_awareness, do: :auto
+
   # has the user successfully posted before?
   defp old_user?(%User{} = u) do
     u.note_count > 0 || u.follower_count > 0
index fad8d873bd5ffd6e4517f1efd23c15d61a8512fb..c438b8f704569dc51731df2473243e66cee97f8b 100644 (file)
@@ -10,6 +10,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do
 
   @reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless])
 
+  def history_awareness, do: :auto
+
   def filter_by_summary(
         %{data: %{"summary" => parent_summary}} = _in_reply_to,
         %{"summary" => child_summary} = child
@@ -27,8 +29,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do
 
   def filter_by_summary(_in_reply_to, child), do: child
 
-  def filter(%{"type" => "Create", "object" => child_object} = object)
-      when is_map(child_object) do
+  def filter(%{"type" => type, "object" => child_object} = object)
+      when type in ["Create", "Update"] and is_map(child_object) do
     child =
       child_object["inReplyTo"]
       |> Object.normalize(fetch: false)
index b7db4fa3d56de8ea398a9652d19acd751486d409..b5ad8b5b4d8b7ff2137588cde1dda9f9ca90ec67 100644 (file)
@@ -16,6 +16,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do
 
   @behaviour Pleroma.Web.ActivityPub.MRF.Policy
 
+  @impl true
+  def history_awareness, do: :manual
+
   defp check_reject(message, hashtags) do
     if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do
       {:reject, "[HashtagPolicy] Matches with rejected keyword"}
@@ -47,22 +50,46 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do
 
   defp check_ftl_removal(message, _hashtags), do: {:ok, message}
 
-  defp check_sensitive(message, hashtags) do
-    if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
-      {:ok, Kernel.put_in(message, ["object", "sensitive"], true)}
-    else
-      {:ok, message}
-    end
+  defp check_sensitive(message) do
+    {:ok, new_object} =
+      Object.Updater.do_with_history(message["object"], fn object ->
+        hashtags = Object.hashtags(%Object{data: object})
+
+        if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
+          {:ok, Map.put(object, "sensitive", true)}
+        else
+          {:ok, object}
+        end
+      end)
+
+    {:ok, Map.put(message, "object", new_object)}
   end
 
   @impl true
-  def filter(%{"type" => "Create", "object" => object} = message) do
-    hashtags = Object.hashtags(%Object{data: object})
+  def filter(%{"type" => type, "object" => object} = message) when type in ["Create", "Update"] do
+    history_items =
+      with %{"formerRepresentations" => %{"orderedItems" => items}} <- object do
+        items
+      else
+        _ -> []
+      end
+
+    historical_hashtags =
+      Enum.reduce(history_items, [], fn item, acc ->
+        acc ++ Object.hashtags(%Object{data: item})
+      end)
+
+    hashtags = Object.hashtags(%Object{data: object}) ++ historical_hashtags
 
     if hashtags != [] do
       with {:ok, message} <- check_reject(message, hashtags),
-           {:ok, message} <- check_ftl_removal(message, hashtags),
-           {:ok, message} <- check_sensitive(message, hashtags) do
+           {:ok, message} <-
+             (if "type" == "Create" do
+                check_ftl_removal(message, hashtags)
+              else
+                {:ok, message}
+              end),
+           {:ok, message} <- check_sensitive(message) do
         {:ok, message}
       end
     else
index 1383fa757365836c2d102212f2c0c9868cb9ff62..7c921fc767d73fcaac873ca36c393145e02fd1d7 100644 (file)
@@ -27,24 +27,46 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
   end
 
   defp check_reject(%{"object" => %{} = object} = message) do
-    payload = object_payload(object)
-
-    if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
-         string_matches?(payload, pattern)
-       end) do
-      {:reject, "[KeywordPolicy] Matches with rejected keyword"}
-    else
+    with {:ok, _new_object} <-
+           Pleroma.Object.Updater.do_with_history(object, fn object ->
+             payload = object_payload(object)
+
+             if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
+                  string_matches?(payload, pattern)
+                end) do
+               {:reject, "[KeywordPolicy] Matches with rejected keyword"}
+             else
+               {:ok, message}
+             end
+           end) do
       {:ok, message}
+    else
+      e -> e
     end
   end
 
-  defp check_ftl_removal(%{"to" => to, "object" => %{} = object} = message) do
-    payload = object_payload(object)
+  defp check_ftl_removal(%{"type" => "Create", "to" => to, "object" => %{} = object} = message) do
+    check_keyword = fn object ->
+      payload = object_payload(object)
 
-    if Pleroma.Constants.as_public() in to and
-         Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
+      if Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
            string_matches?(payload, pattern)
          end) do
+        {:should_delist, nil}
+      else
+        {:ok, %{}}
+      end
+    end
+
+    should_delist? = fn object ->
+      with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check_keyword) do
+        false
+      else
+        _ -> true
+      end
+    end
+
+    if Pleroma.Constants.as_public() in to and should_delist?.(object) do
       to = List.delete(to, Pleroma.Constants.as_public())
       cc = [Pleroma.Constants.as_public() | message["cc"] || []]
 
@@ -59,8 +81,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
     end
   end
 
+  defp check_ftl_removal(message) do
+    {:ok, message}
+  end
+
   defp check_replace(%{"object" => %{} = object} = message) do
-    object =
+    replace_kw = fn object ->
       ["content", "name", "summary"]
       |> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end)
       |> Enum.reduce(object, fn field, object ->
@@ -73,6 +99,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
 
         Map.put(object, field, data)
       end)
+      |> (fn object -> {:ok, object} end).()
+    end
+
+    {:ok, object} = Pleroma.Object.Updater.do_with_history(object, replace_kw)
 
     message = Map.put(message, "object", object)
 
@@ -80,7 +110,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
   end
 
   @impl true
-  def filter(%{"type" => "Create", "object" => %{"content" => _content}} = message) do
+  def filter(%{"type" => type, "object" => %{"content" => _content}} = message)
+      when type in ["Create", "Update"] do
     with {:ok, message} <- check_reject(message),
          {:ok, message} <- check_ftl_removal(message),
          {:ok, message} <- check_replace(message) do
index f60a76adfa18eca7a9cc4a4a3ddb4a0cd88f6868..72455afd0a5f518267616a6da965adf42308ee6a 100644 (file)
@@ -15,6 +15,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
     recv_timeout: 10_000
   ]
 
+  @impl true
+  def history_awareness, do: :auto
+
   defp prefetch(url) do
     # Fetching only proxiable resources
     if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
@@ -53,10 +56,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
   end
 
   @impl true
-  def filter(
-        %{"type" => "Create", "object" => %{"attachment" => attachments} = _object} = message
-      )
-      when is_list(attachments) and length(attachments) > 0 do
+  def filter(%{"type" => type, "object" => %{"attachment" => attachments} = _object} = message)
+      when type in ["Create", "Update"] and is_list(attachments) and length(attachments) > 0 do
     preload(message)
 
     {:ok, message}
index b2939a4d6db03e839b6c29a99243d0a939a74258..19637a38d0f1a0e54aca234e15fc5b2c9b63a415 100644 (file)
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
   @impl true
   def filter(%{"actor" => actor} = object) do
     with true <- is_local?(actor),
+         true <- is_eligible_type?(object),
          true <- is_note?(object),
          false <- has_attachment?(object),
          true <- only_mentions?(object) do
@@ -32,7 +33,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
   end
 
   defp has_attachment?(%{
-         "type" => "Create",
          "object" => %{"type" => "Note", "attachment" => attachments}
        })
        when length(attachments) > 0,
@@ -40,23 +40,13 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
 
   defp has_attachment?(_), do: false
 
-  defp only_mentions?(%{"type" => "Create", "object" => %{"type" => "Note", "source" => source}})
-       when is_binary(source) do
-    non_mentions =
-      source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
+  defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do
+    source =
+      case source do
+        %{"content" => text} -> text
+        _ -> source
+      end
 
-    if non_mentions > 0 do
-      false
-    else
-      true
-    end
-  end
-
-  defp only_mentions?(%{
-         "type" => "Create",
-         "object" => %{"type" => "Note", "source" => %{"content" => source}}
-       })
-       when is_binary(source) do
     non_mentions =
       source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
 
@@ -69,9 +59,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
 
   defp only_mentions?(_), do: false
 
-  defp is_note?(%{"type" => "Create", "object" => %{"type" => "Note"}}), do: true
+  defp is_note?(%{"object" => %{"type" => "Note"}}), do: true
   defp is_note?(_), do: false
 
+  defp is_eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true
+  defp is_eligible_type?(_), do: false
+
   @impl true
   def describe, do: {:ok, %{}}
 end
index 90272766c4f1d84489f44f20fd5bb59a2a1ecf60..f25bb4efd537e016fba06b835a18e5276bbd0a62 100644 (file)
@@ -6,14 +6,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do
   @moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)"
   @behaviour Pleroma.Web.ActivityPub.MRF.Policy
 
+  @impl true
+  def history_awareness, do: :auto
+
   @impl true
   def filter(
         %{
-          "type" => "Create",
+          "type" => type,
           "object" => %{"content" => content, "attachment" => _} = _child_object
         } = object
       )
-      when content in [".", "<p>.</p>"] do
+      when type in ["Create", "Update"] and content in [".", "<p>.</p>"] do
     {:ok, put_in(object, ["object", "content"], "")}
   end
 
index 0d71467387a98793606b3b7496792f71de996ef4..151c6ed2053a7d8a281b13c15d9a5f5ded77a672 100644 (file)
@@ -9,7 +9,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do
   @behaviour Pleroma.Web.ActivityPub.MRF.Policy
 
   @impl true
-  def filter(%{"type" => "Create", "object" => child_object} = object) do
+  def history_awareness, do: :auto
+
+  @impl true
+  def filter(%{"type" => type, "object" => child_object} = object)
+      when type in ["Create", "Update"] do
     scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy])
 
     content =
index a4a960c013f110e21c5d811299fc34810b9c1016..75209b2db012a4db78515e55ee7a9e86bfa3105f 100644 (file)
@@ -12,5 +12,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.Policy do
               label: String.t(),
               description: String.t()
             }
-  @optional_callbacks config_description: 0
+  @callback history_awareness() :: :auto | :manual
+  @optional_callbacks config_description: 0, history_awareness: 0
 end
index c631cc85fad4c814107a8c6d369dfcf66cd82537..415c5d2ddb87937605684f547bba6fe457426f03 100644 (file)
@@ -256,10 +256,35 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do
 
   def filter(object), do: {:ok, object}
 
+  defp obfuscate(string) when is_binary(string) do
+    string
+    |> to_charlist()
+    |> Enum.with_index()
+    |> Enum.map(fn
+      {?., _index} ->
+        ?.
+
+      {char, index} ->
+        if 3 <= index && index < String.length(string) - 3, do: ?*, else: char
+    end)
+    |> to_string()
+  end
+
+  defp maybe_obfuscate(host, obfuscations) do
+    if MRF.subdomain_match?(obfuscations, host) do
+      obfuscate(host)
+    else
+      host
+    end
+  end
+
   @impl true
   def describe do
     exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples()
 
+    obfuscations =
+      Config.get([:mrf, :transparency_obfuscate_domains], []) |> MRF.subdomains_regex()
+
     mrf_simple_excluded =
       Config.get(:mrf_simple)
       |> Enum.map(fn {rule, instances} ->
@@ -269,7 +294,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do
     mrf_simple =
       mrf_simple_excluded
       |> Enum.map(fn {rule, instances} ->
-        {rule, Enum.map(instances, fn {host, _} -> host end)}
+        {rule, Enum.map(instances, fn {host, _} -> maybe_obfuscate(host, obfuscations) end)}
       end)
       |> Map.new()
 
@@ -286,7 +311,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do
       |> Enum.map(fn {rule, instances} ->
         instances =
           instances
-          |> Enum.map(fn {host, reason} -> {host, %{"reason" => reason}} end)
+          |> Enum.map(fn {host, reason} ->
+            {maybe_obfuscate(host, obfuscations), %{"reason" => reason}}
+          end)
           |> Map.new()
 
         {rule, instances}
index 283cd884c948e5c4d243708ab9ca0db6e107769c..cb0cc9ed792f5287ce0f74389569fae8d20cff06 100644 (file)
@@ -86,8 +86,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
         meta
       )
       when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
-    with {:ok, object_data} <- cast_and_apply(object),
-         meta = Keyword.put(meta, :object_data, object_data |> stringify_keys),
+    with {:ok, object_data} <- cast_and_apply_and_stringify_with_history(object),
+         meta = Keyword.put(meta, :object_data, object_data),
          {:ok, create_activity} <-
            create_activity
            |> CreateGenericValidator.cast_and_validate(meta)
@@ -111,16 +111,50 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
       end
 
     with {:ok, object} <-
-           object
-           |> validator.cast_and_validate()
-           |> Ecto.Changeset.apply_action(:insert) do
-      object = stringify_keys(object)
+           do_separate_with_history(object, fn object ->
+             with {:ok, object} <-
+                    object
+                    |> validator.cast_and_validate()
+                    |> Ecto.Changeset.apply_action(:insert) do
+               object = stringify_keys(object)
+
+               # Insert copy of hashtags as strings for the non-hashtag table indexing
+               tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object})
+               object = Map.put(object, "tag", tag)
+
+               {:ok, object}
+             end
+           end) do
+      {:ok, object, meta}
+    end
+  end
 
-      # Insert copy of hashtags as strings for the non-hashtag table indexing
-      tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object})
-      object = Map.put(object, "tag", tag)
+  def validate(
+        %{"type" => "Update", "object" => %{"type" => objtype} = object} = update_activity,
+        meta
+      )
+      when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
+    with {_, false} <- {:local, Access.get(meta, :local, false)},
+         {_, {:ok, object_data, _}} <- {:object_validation, validate(object, meta)},
+         meta = Keyword.put(meta, :object_data, object_data),
+         {:ok, update_activity} <-
+           update_activity
+           |> UpdateValidator.cast_and_validate()
+           |> Ecto.Changeset.apply_action(:insert) do
+      update_activity = stringify_keys(update_activity)
+      {:ok, update_activity, meta}
+    else
+      {:local, _} ->
+        with {:ok, object} <-
+               update_activity
+               |> UpdateValidator.cast_and_validate()
+               |> Ecto.Changeset.apply_action(:insert) do
+          object = stringify_keys(object)
+          {:ok, object, meta}
+        end
 
-      {:ok, object, meta}
+      {:object_validation, e} ->
+        e
     end
   end
 
@@ -160,6 +194,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
 
   def validate(o, m), do: {:error, {:validator_not_set, {o, m}}}
 
+  def cast_and_apply_and_stringify_with_history(object) do
+    do_separate_with_history(object, fn object ->
+      with {:ok, object_data} <- cast_and_apply(object),
+           object_data <- object_data |> stringify_keys() do
+        {:ok, object_data}
+      end
+    end)
+  end
+
   def cast_and_apply(%{"type" => "Question"} = object) do
     QuestionValidator.cast_and_apply(object)
   end
@@ -214,4 +257,54 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
     Object.normalize(object["object"], fetch: true)
     :ok
   end
+
+  defp for_each_history_item(
+         %{"type" => "OrderedCollection", "orderedItems" => items} = history,
+         object,
+         fun
+       ) do
+    processed_items =
+      Enum.map(items, fn item ->
+        with item <- Map.put(item, "id", object["id"]),
+             {:ok, item} <- fun.(item) do
+          item
+        else
+          _ -> nil
+        end
+      end)
+
+    if Enum.all?(processed_items, &(not is_nil(&1))) do
+      {:ok, Map.put(history, "orderedItems", processed_items)}
+    else
+      {:error, :invalid_history}
+    end
+  end
+
+  defp for_each_history_item(nil, _object, _fun) do
+    {:ok, nil}
+  end
+
+  defp for_each_history_item(_, _object, _fun) do
+    {:error, :invalid_history}
+  end
+
+  # fun is (object -> {:ok, validated_object_with_string_keys})
+  defp do_separate_with_history(object, fun) do
+    with history <- object["formerRepresentations"],
+         object <- Map.drop(object, ["formerRepresentations"]),
+         {_, {:ok, object}} <- {:main_body, fun.(object)},
+         {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do
+      object =
+        if history do
+          Map.put(object, "formerRepresentations", history)
+        else
+          object
+        end
+
+      {:ok, object}
+    else
+      {:main_body, e} -> e
+      {:history_items, e} -> e
+    end
+  end
 end
index eee7629ad1cac7511f6a3a5b1b0a09b6f987fd6a..0d45421e2555cd64b39f6c3807b7fddb8d6454ca 100644 (file)
@@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
   use Ecto.Schema
   alias Pleroma.User
   alias Pleroma.EctoType.ActivityPub.ObjectValidators
-  alias Pleroma.Object.Fetcher
   alias Pleroma.Web.CommonAPI.Utils
   alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
   alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@@ -54,23 +53,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
   defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"])
   defp fix_url(data), do: data
 
-  defp fix_tag(%{"tag" => tag} = data) when is_list(tag), do: data
+  defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do
+    Map.put(data, "tag", Enum.filter(tag, &is_map/1))
+  end
+
   defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag])
   defp fix_tag(data), do: Map.drop(data, ["tag"])
 
-  defp fix_replies(%{"replies" => %{"first" => %{"items" => replies}}} = data)
-       when is_list(replies),
-       do: Map.put(data, "replies", replies)
-
-  defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies),
-    do: Map.put(data, "replies", replies)
-
-  defp fix_replies(%{"replies" => replies} = data) when is_bitstring(replies),
-    do: Map.drop(data, ["replies"])
+  defp fix_replies(%{"replies" => replies} = data) when is_list(replies), do: data
 
   defp fix_replies(%{"replies" => %{"first" => first}} = data) do
-    with {:ok, %{"orderedItems" => replies}} <-
-           Fetcher.fetch_and_contain_remote_object_from_id(first) do
+    with {:ok, replies} <- Akkoma.Collections.Fetcher.fetch_collection(first) do
       Map.put(data, "replies", replies)
     else
       {:error, _} ->
@@ -79,7 +72,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
     end
   end
 
-  defp fix_replies(data), do: data
+  defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies),
+    do: Map.put(data, "replies", replies)
+
+  defp fix_replies(data), do: Map.delete(data, "replies")
 
   defp remote_mention_resolver(
          %{"id" => ap_id, "tag" => tags},
@@ -108,6 +104,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
   end
 
   # https://github.com/misskey-dev/misskey/pull/8787
+  # Misskey has an awful tendency to drop all custom formatting when it sends remotely
+  # So this basically reprocesses their MFM source
   defp fix_misskey_content(
          %{"source" => %{"mediaType" => "text/x.misskeymarkdown", "content" => content}} = object
        )
index ffdb16976f30263fc71b7f7dea9592eff4d5b4e3..dba18a3d0b2baf2000f043c3a6773b8e465f3d40 100644 (file)
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
 
   @primary_key false
   embedded_schema do
+    field(:id, :string)
     field(:type, :string)
     field(:mediaType, :string, default: "application/octet-stream")
     field(:name, :string)
@@ -43,7 +44,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
       |> fix_url()
 
     struct
-    |> cast(data, [:type, :mediaType, :name, :blurhash])
+    |> cast(data, [:id, :type, :mediaType, :name, :blurhash])
     |> cast_embed(:url, with: &url_changeset/2, required: true)
     |> validate_inclusion(:type, ~w[Link Document Audio Image Video])
     |> validate_required([:type, :mediaType])
index 49aba68af6511842d4ba8f7befb3ad7b17e6b64f..db28c38eff39ace8af27242ee1ac99640a425731 100644 (file)
@@ -33,6 +33,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFields do
       field(:content, :string)
 
       field(:published, ObjectValidators.DateTime)
+      field(:updated, ObjectValidators.DateTime)
       field(:emoji, ObjectValidators.Emoji, default: %{})
       embeds_many(:attachment, AttachmentValidator)
     end
index 779c8b622e456c452095d8aac599803fae3aefa9..6fa2bbb99cb3312fa91f2cb0fbfd0ae0ccb067fa 100644 (file)
@@ -7,8 +7,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
   alias Pleroma.Object
   alias Pleroma.Object.Containment
   alias Pleroma.User
-  alias Pleroma.Web.ActivityPub.Transmogrifier
   alias Pleroma.Web.ActivityPub.Utils
+  require Pleroma.Constants
 
   def cast_and_filter_recipients(message, field, follower_collection, field_fallback \\ []) do
     {:ok, data} = ObjectValidators.Recipients.cast(message[field] || field_fallback)
@@ -32,7 +32,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
     |> cast_and_filter_recipients("cc", follower_collection)
     |> cast_and_filter_recipients("bto", follower_collection)
     |> cast_and_filter_recipients("bcc", follower_collection)
-    |> Transmogrifier.fix_implicit_addressing(follower_collection)
+    |> fix_implicit_addressing(follower_collection)
   end
 
   def fix_activity_addressing(activity) do
@@ -43,7 +43,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
     |> cast_and_filter_recipients("cc", follower_collection)
     |> cast_and_filter_recipients("bto", follower_collection)
     |> cast_and_filter_recipients("bcc", follower_collection)
-    |> Transmogrifier.fix_implicit_addressing(follower_collection)
+    |> fix_implicit_addressing(follower_collection)
   end
 
   def fix_actor(data) do
@@ -73,4 +73,27 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
 
     Map.put(data, "to", to)
   end
+
+  # if as:Public is addressed, then make sure the followers collection is also addressed
+  # so that the activities will be delivered to local users.
+  def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
+    recipients = to ++ cc
+
+    if followers_collection not in recipients do
+      cond do
+        Pleroma.Constants.as_public() in cc ->
+          to = to ++ [followers_collection]
+          Map.put(object, "to", to)
+
+        Pleroma.Constants.as_public() in to ->
+          cc = cc ++ [followers_collection]
+          Map.put(object, "cc", cc)
+
+        true ->
+          object
+      end
+    else
+      object
+    end
+  end
 end
index 803b5d5a11d009b2752e871a29062701e919ac3f..d868c39156a7c1ab998e916e2de554702727f68c 100644 (file)
@@ -13,7 +13,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
   alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
-  alias Pleroma.Web.ActivityPub.Transmogrifier
 
   import Ecto.Changeset
 
@@ -67,7 +66,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do
     |> CommonFixes.cast_and_filter_recipients("cc", follower_collection, object["cc"])
     |> CommonFixes.cast_and_filter_recipients("bto", follower_collection, object["bto"])
     |> CommonFixes.cast_and_filter_recipients("bcc", follower_collection, object["bcc"])
-    |> Transmogrifier.fix_implicit_addressing(follower_collection)
+    |> CommonFixes.fix_implicit_addressing(follower_collection)
   end
 
   def fix(data, meta) do
index 306a57a93bf23740a0ada1501c275ac9e6a86963..6109a0355e93f12a32dafd9ff0877c6e55be3a03 100644 (file)
@@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do
   import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
 
   @primary_key false
-  @emoji_regex ~r/:[A-Za-z0-9_-]+:/
+  @emoji_regex ~r/:[A-Za-z0-9_-]+(@.+)?:/
 
   embedded_schema do
     quote do
index a1fae47f58aeb84ad7baba87826a3a1a7a058eb5..2f0839c5b1569c7c07a045ef7b9d213241768f83 100644 (file)
@@ -51,7 +51,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do
     with actor = get_field(cng, :actor),
          object = get_field(cng, :object),
          {:ok, object_id} <- ObjectValidators.ObjectID.cast(object),
-         true <- actor == object_id do
+         actor_uri <- URI.parse(actor),
+         object_uri <- URI.parse(object_id),
+         true <- actor_uri.host == object_uri.host do
       cng
     else
       _e ->
index 439268470c544d0c4cbf7b55989a4f1635635d8b..43b1b089b6e938496e325973f3d7a11133f6c391 100644 (file)
@@ -23,6 +23,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   alias Pleroma.Web.Streamer
   alias Pleroma.Workers.PollWorker
 
+  require Pleroma.Constants
   require Logger
 
   @logger Pleroma.Config.get([:side_effects, :logger], Logger)
@@ -150,23 +151,26 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
 
   # Tasks this handles:
   # - Update the user
+  # - Update a non-user object (Note, Question, etc.)
   #
   # For a local user, we also get a changeset with the full information, so we
   # can update non-federating, non-activitypub settings as well.
   @impl true
   def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do
-    if changeset = Keyword.get(meta, :user_update_changeset) do
-      changeset
-      |> User.update_and_set_cache()
+    updated_object_id = updated_object["id"]
+
+    with {_, true} <- {:has_id, is_binary(updated_object_id)},
+         %{"type" => type} <- updated_object,
+         {_, is_user} <- {:is_user, type in Pleroma.Constants.actor_types()} do
+      if is_user do
+        handle_update_user(object, meta)
+      else
+        handle_update_object(object, meta)
+      end
     else
-      {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object)
-
-      User.get_by_ap_id(updated_object["id"])
-      |> User.remote_user_changeset(new_user_data)
-      |> User.update_and_set_cache()
+      _ ->
+        {:ok, object, meta}
     end
-
-    {:ok, object, meta}
   end
 
   # Tasks this handles:
@@ -395,6 +399,79 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     {:ok, object, meta}
   end
 
+  defp handle_update_user(
+         %{data: %{"type" => "Update", "object" => updated_object}} = object,
+         meta
+       ) do
+    if changeset = Keyword.get(meta, :user_update_changeset) do
+      changeset
+      |> User.update_and_set_cache()
+    else
+      {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object)
+
+      User.get_by_ap_id(updated_object["id"])
+      |> User.remote_user_changeset(new_user_data)
+      |> User.update_and_set_cache()
+    end
+
+    {:ok, object, meta}
+  end
+
+  defp handle_update_object(
+         %{data: %{"type" => "Update", "object" => updated_object}} = object,
+         meta
+       ) do
+    orig_object_ap_id = updated_object["id"]
+    orig_object = Object.get_by_ap_id(orig_object_ap_id)
+    orig_object_data = orig_object.data
+
+    updated_object =
+      if meta[:local] do
+        # If this is a local Update, we don't process it by transmogrifier,
+        # so we use the embedded object as-is.
+        updated_object
+      else
+        meta[:object_data]
+      end
+
+    if orig_object_data["type"] in Pleroma.Constants.updatable_object_types() do
+      %{
+        updated_data: updated_object_data,
+        updated: updated,
+        used_history_in_new_object?: used_history_in_new_object?
+      } = Object.Updater.make_new_object_data_from_update_object(orig_object_data, updated_object)
+
+      changeset =
+        orig_object
+        |> Repo.preload(:hashtags)
+        |> Object.change(%{data: updated_object_data})
+
+      with {:ok, new_object} <- Repo.update(changeset),
+           {:ok, _} <- Object.invalid_object_cache(new_object),
+           {:ok, _} <- Object.set_cache(new_object),
+           # The metadata/utils.ex uses the object id for the cache.
+           {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(new_object.id) do
+        if used_history_in_new_object? do
+          with create_activity when not is_nil(create_activity) <-
+                 Pleroma.Activity.get_create_by_object_ap_id(orig_object_ap_id),
+               {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(create_activity.id) do
+            nil
+          else
+            _ -> nil
+          end
+        end
+
+        if updated do
+          object
+          |> Activity.normalize()
+          |> ActivityPub.notify_and_stream()
+        end
+      end
+    end
+
+    {:ok, object, meta}
+  end
+
   def handle_object_creation(%{"type" => "Question"} = object, activity, meta) do
     with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
       PollWorker.schedule_poll_end(activity)
index d2077967c31458bf4d10abfa181bf6574c6a4f92..b9d8536105c9173c30d78ee9d00a0ac989d6c241 100644 (file)
@@ -19,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
   alias Pleroma.Web.ActivityPub.Pipeline
   alias Pleroma.Web.ActivityPub.Utils
   alias Pleroma.Web.ActivityPub.Visibility
+  alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
   alias Pleroma.Web.Federator
   alias Pleroma.Workers.TransmogrifierWorker
 
@@ -95,29 +96,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     |> Map.put("cc", final_cc)
   end
 
-  # if as:Public is addressed, then make sure the followers collection is also addressed
-  # so that the activities will be delivered to local users.
-  def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
-    recipients = to ++ cc
-
-    if followers_collection not in recipients do
-      cond do
-        Pleroma.Constants.as_public() in cc ->
-          to = to ++ [followers_collection]
-          Map.put(object, "to", to)
-
-        Pleroma.Constants.as_public() in to ->
-          cc = cc ++ [followers_collection]
-          Map.put(object, "cc", cc)
-
-        true ->
-          object
-      end
-    else
-      object
-    end
-  end
-
   def fix_addressing(object) do
     {:ok, %User{follower_address: follower_collection}} =
       object
@@ -130,7 +108,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     |> fix_addressing_list("bto")
     |> fix_addressing_list("bcc")
     |> fix_explicit_addressing(follower_collection)
-    |> fix_implicit_addressing(follower_collection)
+    |> CommonFixes.fix_implicit_addressing(follower_collection)
   end
 
   def fix_actor(%{"attributedTo" => actor} = object) do
@@ -721,6 +699,24 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     |> strip_internal_fields
     |> strip_internal_tags
     |> set_type
+    |> maybe_process_history
+  end
+
+  defp maybe_process_history(%{"formerRepresentations" => %{"orderedItems" => history}} = object) do
+    processed_history =
+      Enum.map(
+        history,
+        fn
+          item when is_map(item) -> prepare_object(item)
+          item -> item
+        end
+      )
+
+    put_in(object, ["formerRepresentations", "orderedItems"], processed_history)
+  end
+
+  defp maybe_process_history(object) do
+    object
   end
 
   #  @doc
@@ -745,6 +741,21 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     {:ok, data}
   end
 
+  def prepare_outgoing(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data)
+      when objtype in Pleroma.Constants.updatable_object_types() do
+    object =
+      object
+      |> prepare_object
+
+    data =
+      data
+      |> Map.put("object", object)
+      |> Map.merge(Utils.make_json_ld_header())
+      |> Map.delete("bcc")
+
+    {:ok, data}
+  end
+
   def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
     object =
       object_id
index 5e5df488839aaf64103477065c74d593852164b9..008aec475b19761964c100001e7ea657ce349b1f 100644 (file)
@@ -329,7 +329,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do
         object
       ) do
     reactions = get_cached_emoji_reactions(object)
-    emoji = stripped_emoji_name(emoji)
+    emoji = Pleroma.Emoji.stripped_name(emoji)
     url = emoji_url(emoji, activity)
 
     new_reactions =
@@ -356,12 +356,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     update_element_in_object("reaction", new_reactions, object, count)
   end
 
-  defp stripped_emoji_name(name) do
-    name
-    |> String.replace_leading(":", "")
-    |> String.replace_trailing(":", "")
-  end
-
   defp emoji_url(
          name,
          %Activity{
@@ -384,7 +378,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do
         %Activity{data: %{"content" => emoji, "actor" => actor}} = activity,
         object
       ) do
-    emoji = stripped_emoji_name(emoji)
+    emoji = Pleroma.Emoji.stripped_name(emoji)
     reactions = get_cached_emoji_reactions(object)
     url = emoji_url(emoji, activity)
 
@@ -472,18 +466,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do
     {:ok, activity}
   end
 
-  def update_follow_state(
-        %Activity{} = activity,
-        state
-      ) do
-    new_data = Map.put(activity.data, "state", state)
-    changeset = Changeset.change(activity, data: new_data)
-
-    with {:ok, activity} <- Repo.update(changeset) do
-      {:ok, activity}
-    end
-  end
-
   @doc """
   Makes a follow activity data for the given follower and followed
   """
@@ -525,19 +507,37 @@ defmodule Pleroma.Web.ActivityPub.Utils do
 
   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)
-
     emoji = Pleroma.Emoji.maybe_quote(emoji)
 
     "EmojiReact"
     |> Activity.Queries.by_type()
     |> where(actor: ^ap_id)
-    |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji))
+    |> custom_emoji_discriminator(emoji)
     |> Activity.Queries.by_object_id(object_ap_id)
     |> order_by([activity], fragment("? desc nulls last", activity.id))
     |> limit(1)
     |> Repo.one()
   end
 
+  defp custom_emoji_discriminator(query, emoji) do
+    if String.contains?(emoji, "@") do
+      stripped = Pleroma.Emoji.stripped_name(emoji)
+      [name, domain] = String.split(stripped, "@")
+      domain_pattern = "%" <> domain <> "%"
+      emoji_pattern = Pleroma.Emoji.maybe_quote(name)
+
+      query
+      |> where([activity], fragment("?->>'content' = ?
+        AND EXISTS (
+          SELECT FROM jsonb_array_elements(?->'tag') elem
+          WHERE elem->>'id' ILIKE ?
+        )", activity.data, ^emoji_pattern, activity.data, ^domain_pattern))
+    else
+      query
+      |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji))
+    end
+  end
+
   #### Announce-related helpers
 
   @doc """
diff --git a/lib/pleroma/web/akkoma_api/controllers/translation_controller.ex b/lib/pleroma/web/akkoma_api/controllers/translation_controller.ex
new file mode 100644 (file)
index 0000000..9983a7e
--- /dev/null
@@ -0,0 +1,43 @@
+defmodule Pleroma.Web.AkkomaAPI.TranslationController do
+  use Pleroma.Web, :controller
+
+  alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+  @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+
+  @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
+  plug(
+    OAuthScopesPlug,
+    %{@unauthenticated_access | scopes: ["read:statuses"]}
+    when action in [
+           :languages
+         ]
+  )
+
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TranslationOperation
+
+  action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+  @doc "GET /api/v1/akkoma/translation/languages"
+  def languages(conn, _params) do
+    with {:ok, source_languages, dest_languages} <- get_languages() do
+      conn
+      |> json(%{source: source_languages, target: dest_languages})
+    else
+      e -> IO.inspect(e)
+    end
+  end
+
+  defp get_languages do
+    module = Pleroma.Config.get([:translator, :module])
+
+    @cachex.fetch!(:translations_cache, "languages:#{module}}", fn _ ->
+      with {:ok, source_languages, dest_languages} <- module.languages() do
+        {:ok, source_languages, dest_languages}
+      else
+        {:error, err} -> {:ignore, {:error, err}}
+      end
+    end)
+  end
+end
index a5da8b58e6a78c47e3ff18249d63c7a00c8f94e3..65877cc64f6e78293332e03ae70a11a7eae8dc7c 100644 (file)
@@ -6,9 +6,13 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
   alias OpenApiSpex.Operation
   alias OpenApiSpex.Schema
   alias Pleroma.Web.ApiSpec.AccountOperation
+  alias Pleroma.Web.ApiSpec.Schemas.Account
   alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.Attachment
   alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
+  alias Pleroma.Web.ApiSpec.Schemas.Emoji
   alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.Poll
   alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
   alias Pleroma.Web.ApiSpec.Schemas.Status
   alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
@@ -406,6 +410,75 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
     }
   end
 
+  def translate_operation do
+    %Operation{
+      tags: ["Retrieve status translation"],
+      summary: "Translate status",
+      description: "View the translation of a given status",
+      operationId: "StatusController.translation",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [id_param(), language_param(), source_language_param()],
+      responses: %{
+        200 => Operation.response("Translation", "application/json", translation()),
+        400 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def show_history_operation do
+    %Operation{
+      tags: ["Retrieve status history"],
+      summary: "Status history",
+      description: "View history of a status",
+      operationId: "StatusController.show_history",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [
+        id_param()
+      ],
+      responses: %{
+        200 => status_history_response(),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def show_source_operation do
+    %Operation{
+      tags: ["Retrieve status source"],
+      summary: "Status source",
+      description: "View source of a status",
+      operationId: "StatusController.show_source",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [
+        id_param()
+      ],
+      responses: %{
+        200 => status_source_response(),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Update status"],
+      summary: "Update status",
+      description: "Change the content of a status",
+      operationId: "StatusController.update",
+      security: [%{"oAuth" => ["write:statuses"]}],
+      parameters: [
+        id_param()
+      ],
+      requestBody: request_body("Parameters", update_request(), required: true),
+      responses: %{
+        200 => status_response(),
+        403 => Operation.response("Forbidden", "application/json", ApiError),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
   def array_of_statuses do
     %Schema{type: :array, items: Status, example: [Status.schema().example]}
   end
@@ -514,6 +587,60 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
     }
   end
 
+  defp update_request do
+    %Schema{
+      title: "StatusUpdateRequest",
+      type: :object,
+      properties: %{
+        status: %Schema{
+          type: :string,
+          nullable: true,
+          description:
+            "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided."
+        },
+        media_ids: %Schema{
+          nullable: true,
+          type: :array,
+          items: %Schema{type: :string},
+          description: "Array of Attachment ids to be attached as media."
+        },
+        poll: poll_params(),
+        sensitive: %Schema{
+          allOf: [BooleanLike],
+          nullable: true,
+          description: "Mark status and attached media as sensitive?"
+        },
+        spoiler_text: %Schema{
+          type: :string,
+          nullable: true,
+          description:
+            "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field."
+        },
+        content_type: %Schema{
+          type: :string,
+          nullable: true,
+          description:
+            "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint."
+        },
+        to: %Schema{
+          type: :array,
+          nullable: true,
+          items: %Schema{type: :string},
+          description:
+            "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
+        }
+      },
+      example: %{
+        "status" => "What time is it?",
+        "sensitive" => "false",
+        "poll" => %{
+          "options" => ["Cofe", "Adventure"],
+          "expires_in" => 420
+        }
+      }
+    }
+  end
+
   def poll_params do
     %Schema{
       nullable: true,
@@ -552,10 +679,99 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
     )
   end
 
+  defp language_param do
+    Operation.parameter(:language, :path, :string, "ISO 639 language code", example: "en")
+  end
+
+  defp source_language_param do
+    Operation.parameter(:from, :query, :string, "ISO 639 language code", example: "en")
+  end
+
   defp status_response do
     Operation.response("Status", "application/json", Status)
   end
 
+  defp status_history_response do
+    Operation.response(
+      "Status History",
+      "application/json",
+      %Schema{
+        title: "Status history",
+        description: "Response schema for history of a status",
+        type: :array,
+        items: %Schema{
+          type: :object,
+          properties: %{
+            account: %Schema{
+              allOf: [Account],
+              description: "The account that authored this status"
+            },
+            content: %Schema{
+              type: :string,
+              format: :html,
+              description: "HTML-encoded status content"
+            },
+            sensitive: %Schema{
+              type: :boolean,
+              description: "Is this status marked as sensitive content?"
+            },
+            spoiler_text: %Schema{
+              type: :string,
+              description:
+                "Subject or summary line, below which status content is collapsed until expanded"
+            },
+            created_at: %Schema{
+              type: :string,
+              format: "date-time",
+              description: "The date when this status was created"
+            },
+            media_attachments: %Schema{
+              type: :array,
+              items: Attachment,
+              description: "Media that is attached to this status"
+            },
+            emojis: %Schema{
+              type: :array,
+              items: Emoji,
+              description: "Custom emoji to be used when rendering status content"
+            },
+            poll: %Schema{
+              allOf: [Poll],
+              nullable: true,
+              description: "The poll attached to the status"
+            }
+          }
+        }
+      }
+    )
+  end
+
+  defp status_source_response do
+    Operation.response(
+      "Status Source",
+      "application/json",
+      %Schema{
+        type: :object,
+        properties: %{
+          id: FlakeID,
+          text: %Schema{
+            type: :string,
+            description: "Raw source of status content"
+          },
+          spoiler_text: %Schema{
+            type: :string,
+            description:
+              "Subject or summary line, below which status content is collapsed until expanded"
+          },
+          content_type: %Schema{
+            type: :string,
+            description: "The content type of the source"
+          }
+        }
+      }
+    )
+  end
+
   defp context do
     %Schema{
       title: "StatusContext",
@@ -573,4 +789,20 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
       }
     }
   end
+
+  defp translation do
+    %Schema{
+      title: "StatusTranslation",
+      description: "The translation of a status.",
+      type: :object,
+      required: [:detected_language, :text],
+      properties: %{
+        detected_language: %Schema{
+          type: :string,
+          description: "The detected language of the text"
+        },
+        text: %Schema{type: :string, description: "The translated text"}
+      }
+    }
+  end
 end
diff --git a/lib/pleroma/web/api_spec/operations/translate_operation.ex b/lib/pleroma/web/api_spec/operations/translate_operation.ex
new file mode 100644 (file)
index 0000000..bf02803
--- /dev/null
@@ -0,0 +1,53 @@
+defmodule Pleroma.Web.ApiSpec.TranslationOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+
+  @spec open_api_operation(atom) :: Operation.t()
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  @spec languages_operation() :: Operation.t()
+  def languages_operation() do
+    %Operation{
+      tags: ["Retrieve status translation"],
+      summary: "Translate status",
+      description: "View the translation of a given status",
+      operationId: "AkkomaAPI.TranslationController.languages",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      responses: %{
+        200 =>
+          Operation.response("Translation", "application/json", source_dest_languages_schema())
+      }
+    }
+  end
+
+  defp source_dest_languages_schema do
+    %Schema{
+      type: :object,
+      required: [:source, :target],
+      properties: %{
+        source: languages_schema(),
+        target: languages_schema()
+      }
+    }
+  end
+
+  defp languages_schema do
+    %Schema{
+      type: :array,
+      items: %Schema{
+        type: :object,
+        properties: %{
+          code: %Schema{
+            type: :string
+          },
+          name: %Schema{
+            type: :string
+          }
+        }
+      }
+    }
+  end
+end
index 4a2a246f5bbfd0e5e223fb17573f702fa15c4e76..c025867a2679bc1ced44616dafcf8a5494c1495b 100644 (file)
@@ -405,6 +405,16 @@ defmodule Pleroma.Web.ApiSpec.TwitterUtilOperation do
     }
   end
 
+  def show_subscribe_form_operation do
+    %Operation{
+      tags: ["Accounts"],
+      summary: "Show remote subscribe form",
+      operationId: "UtilController.show_subscribe_form",
+      parameters: [],
+      responses: %{200 => Operation.response("Web Page", "test/html", %Schema{type: :string})}
+    }
+  end
+
   defp delete_account_request do
     %Schema{
       title: "AccountDeleteRequest",
index c5d9119ef0a262a07c3808c2e979e71657c33810..a6df9be94d7e242f0259c6b8746076813f29634b 100644 (file)
@@ -73,6 +73,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
         format: "date-time",
         description: "The date when this status was created"
       },
+      edited_at: %Schema{
+        type: :string,
+        format: "date-time",
+        nullable: true,
+        description: "The date when this status was last edited"
+      },
       emojis: %Schema{
         type: :array,
         items: Emoji,
index bc5e26cf7c68f568734b7b79a48297695c5c58a8..f1f51acf5c9bcc55e15d08987e11b04c3351f5a9 100644 (file)
@@ -209,7 +209,8 @@ defmodule Pleroma.Web.CommonAPI do
          {:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do
       {:ok, activity}
     else
-      _ -> {:error, dgettext("errors", "Could not add reaction emoji")}
+      _ ->
+        {:error, dgettext("errors", "Could not add reaction emoji")}
     end
   end
 
@@ -346,6 +347,41 @@ defmodule Pleroma.Web.CommonAPI do
     end
   end
 
+  def update(user, orig_activity, changes) do
+    with orig_object <- Object.normalize(orig_activity),
+         {:ok, new_object} <- make_update_data(user, orig_object, changes),
+         {:ok, update_data, _} <- Builder.update(user, new_object),
+         {:ok, update, _} <- Pipeline.common_pipeline(update_data, local: true) do
+      {:ok, update}
+    else
+      _ -> {:error, nil}
+    end
+  end
+
+  defp make_update_data(user, orig_object, changes) do
+    kept_params = %{
+      visibility: Visibility.get_visibility(orig_object),
+      in_reply_to_id:
+        with replied_id when is_binary(replied_id) <- orig_object.data["inReplyTo"],
+             %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(replied_id) do
+          activity_id
+        else
+          _ -> nil
+        end
+    }
+
+    params = Map.merge(changes, kept_params)
+
+    with {:ok, draft} <- ActivityDraft.create(user, params) do
+      change =
+        Object.Updater.make_update_object_data(orig_object.data, draft.object, Utils.make_date())
+
+      {:ok, change}
+    else
+      _ -> {:error, nil}
+    end
+  end
+
   @spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()}
   def pin(id, %User{} = user) do
     with %Activity{} = activity <- create_activity_by_id(id),
index 767b2bf0feaeb5d75b0bb972122315ce898052cd..b3a49de441d8b13957ba43263fed476f5c564f67 100644 (file)
@@ -221,7 +221,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
       |> Map.put("emoji", emoji)
       |> Map.put("source", %{
         "content" => draft.status,
-        "mediaType" => draft.params[:content_type]
+        "mediaType" => Utils.get_content_type(draft.params[:content_type])
       })
       |> Map.put("generator", draft.params[:generator])
 
index 61af71acd206f75a46c4892472e549483d9d7e2c..bf03b0a8253fec9cf6b47b27df7a9dddd316ab4a 100644 (file)
@@ -37,7 +37,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
 
   def attachments_from_ids_no_descs(ids) do
     Enum.map(ids, fn media_id ->
-      case Repo.get(Object, media_id) do
+      case get_attachment(media_id) do
         %Object{data: data} -> data
         _ -> nil
       end
@@ -51,13 +51,17 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     {_, descs} = Jason.decode(descs_str)
 
     Enum.map(ids, fn media_id ->
-      with %Object{data: data} <- Repo.get(Object, media_id) do
+      with %Object{data: data} <- get_attachment(media_id) do
         Map.put(data, "name", descs[media_id])
       end
     end)
     |> Enum.reject(&is_nil/1)
   end
 
+  defp get_attachment(media_id) do
+    Repo.get(Object, media_id)
+  end
+
   @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
 
   def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
@@ -219,7 +223,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     |> maybe_add_attachments(draft.attachments, attachment_links)
   end
 
-  defp get_content_type(content_type) do
+  def get_content_type(content_type) do
     if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
       content_type
     else
@@ -285,11 +289,11 @@ defmodule Pleroma.Web.CommonAPI.Utils do
 
   def format_input(text, "text/x.misskeymarkdown", options) do
     text
+    |> Formatter.markdown_to_html()
+    |> MfmParser.Parser.parse()
+    |> MfmParser.Encoder.to_html()
     |> Formatter.linkify(options)
-    |> Formatter.html_escape("text/x.misskeymarkdown")
-    |> (fn {text, mentions, tags} ->
-          {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
-        end).()
+    |> Formatter.html_escape("text/html")
   end
 
   def format_input(text, "text/markdown", options) do
index d2460f51d9edd3da336df33c11f123367e1800c0..7b6e01aad1c5cec815f85b7f187b3d1e1ea5fd9d 100644 (file)
@@ -27,9 +27,21 @@ defmodule Pleroma.Web.MastoFEController do
   def index(conn, _params) do
     with %{assigns: %{user: %User{} = user, token: %Token{app_id: token_app_id} = token}} <- conn,
          {:ok, %{id: ^token_app_id}} <- AuthController.local_mastofe_app() do
+      flavour =
+        [:frontends, :mastodon]
+        |> Pleroma.Config.get()
+        |> Map.get("name", "mastodon-fe")
+
+      index =
+        if flavour == "fedibird-fe" do
+          "fedibird.index.html"
+        else
+          "glitchsoc.index.html"
+        end
+
       conn
       |> put_layout(false)
-      |> render("index.html",
+      |> render(index,
         token: token.token,
         user: user,
         custom_emojis: Pleroma.Emoji.get_all()
index 4920d65dae3b1bb5bbd53c09a7b4434a963d0e44..a9ccaa982dfc5be4f0e370be005441229b971645 100644 (file)
@@ -27,7 +27,8 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do
   def login(conn, %{"code" => auth_token} = params) do
     with {:ok, app} <- local_mastofe_app(),
          {:ok, auth} <- Authorization.get_by_token(app, auth_token),
-         {:ok, oauth_token} <- Token.exchange_token(app, auth) do
+         %User{} = user <- User.get_cached_by_id(auth.user_id),
+         {:ok, oauth_token} <- Token.get_or_exchange_token(auth, app, user) do
       redirect_to =
         conn
         |> local_mastodon_post_login_path()
index ae4432e85c3001b26ec901a2e31ffc4553c0fb91..8e6cf2a6aef43620ef852625fabc423383aa5427 100644 (file)
@@ -51,6 +51,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
     move
     pleroma:emoji_reaction
     poll
+    update
   }
   def index(%{assigns: %{user: user}} = conn, params) do
     params =
index 9ab30742bd16d6d87fe2adaaa02e6d3a3ef65041..31f3b3a8de095d7892cbc23505f3b960f2e9835e 100644 (file)
@@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
   alias Pleroma.Bookmark
   alias Pleroma.Object
   alias Pleroma.Repo
+  alias Pleroma.Config
   alias Pleroma.ScheduledActivity
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPub
@@ -30,6 +31,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
   plug(:skip_public_check when action in [:index, :show])
 
   @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
+  @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
 
   plug(
     OAuthScopesPlug,
@@ -37,7 +39,10 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
     when action in [
            :index,
            :show,
-           :context
+           :context,
+           :translate,
+           :show_history,
+           :show_source
          ]
   )
 
@@ -48,7 +53,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
            :create,
            :delete,
            :reblog,
-           :unreblog
+           :unreblog,
+           :update
          ]
   )
 
@@ -190,6 +196,59 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
     create(%Plug.Conn{conn | body_params: params}, %{})
   end
 
+  @doc "GET /api/v1/statuses/:id/history"
+  def show_history(%{assigns: assigns} = conn, %{id: id} = params) do
+    with user = assigns[:user],
+         %Activity{} = activity <- Activity.get_by_id_with_object(id),
+         true <- Visibility.visible_for_user?(activity, user) do
+      try_render(conn, "history.json",
+        activity: activity,
+        for: user,
+        with_direct_conversation_id: true,
+        with_muted: Map.get(params, :with_muted, false)
+      )
+    else
+      _ -> {:error, :not_found}
+    end
+  end
+
+  @doc "GET /api/v1/statuses/:id/source"
+  def show_source(%{assigns: assigns} = conn, %{id: id} = _params) do
+    with user = assigns[:user],
+         %Activity{} = activity <- Activity.get_by_id_with_object(id),
+         true <- Visibility.visible_for_user?(activity, user) do
+      try_render(conn, "source.json",
+        activity: activity,
+        for: user
+      )
+    else
+      _ -> {:error, :not_found}
+    end
+  end
+
+  @doc "PUT /api/v1/statuses/:id"
+  def update(%{assigns: %{user: user}, body_params: body_params} = conn, %{id: id} = params) do
+    with {_, %Activity{}} = {_, activity} <- {:activity, Activity.get_by_id_with_object(id)},
+         {_, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+         {_, true} <- {:is_create, activity.data["type"] == "Create"},
+         actor <- Activity.user_actor(activity),
+         {_, true} <- {:own_status, actor.id == user.id},
+         changes <- body_params |> put_application(conn),
+         {_, {:ok, _update_activity}} <- {:pipeline, CommonAPI.update(user, activity, changes)},
+         {_, %Activity{}} = {_, activity} <- {:refetched, Activity.get_by_id_with_object(id)} do
+      try_render(conn, "show.json",
+        activity: activity,
+        for: user,
+        with_direct_conversation_id: true,
+        with_muted: Map.get(params, :with_muted, false)
+      )
+    else
+      {:own_status, _} -> {:error, :forbidden}
+      {:pipeline, _} -> {:error, :internal_server_error}
+      _ -> {:error, :not_found}
+    end
+  end
+
   @doc "GET /api/v1/statuses/:id"
   def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do
     with %Activity{} = activity <- Activity.get_by_id_with_object(id),
@@ -418,6 +477,51 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
     )
   end
 
+  @doc "GET /api/v1/statuses/:id/translations/:language"
+  def translate(%{assigns: %{user: user}} = conn, %{id: id, language: language} = params) do
+    with {:enabled, true} <- {:enabled, Config.get([:translator, :enabled])},
+         %Activity{} = activity <- Activity.get_by_id_with_object(id),
+         {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+         translation_module <- Config.get([:translator, :module]),
+         {:ok, detected, translation} <-
+           fetch_or_translate(
+             activity.id,
+             activity.object.data["content"],
+             Map.get(params, :from, nil),
+             language,
+             translation_module
+           ) do
+      json(conn, %{detected_language: detected, text: translation})
+    else
+      {:enabled, false} ->
+        conn
+        |> put_status(:bad_request)
+        |> json(%{"error" => "Translation is not enabled"})
+
+      {:visible, false} ->
+        {:error, :not_found}
+
+      e ->
+        e
+    end
+  end
+
+  defp fetch_or_translate(status_id, text, source_language, target_language, translation_module) do
+    @cachex.fetch!(
+      :translations_cache,
+      "translations:#{status_id}:#{source_language}:#{target_language}",
+      fn _ ->
+        value = translation_module.translate(text, source_language, target_language)
+
+        with {:ok, _, _} <- value do
+          value
+        else
+          _ -> {:ignore, value}
+        end
+      end
+    )
+  end
+
   defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do
     if user.disclose_client do
       %{client_name: client_name, website: website} = Repo.preload(token, :app).app
index 4cfa8d85ca0e04083a52f16bc77c04e3776618d7..4fed1af74de38453eeb7db3d3b9c3e19e074f043 100644 (file)
@@ -26,7 +26,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
       thumbnail:
         URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail))
         |> to_string,
-      languages: ["en"],
+      languages: Keyword.get(instance, :languages, ["en"]),
       registrations: Keyword.get(instance, :registrations_open),
       approval_required: Keyword.get(instance, :account_approval_required),
       # Extra (not present in Mastodon):
@@ -65,6 +65,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
       "shareable_emoji_packs",
       "multifetch",
       "pleroma:api/v1/notifications:include_types_filter",
+      "editing",
       if Config.get([:media_proxy, :enabled]) do
         "media_proxy"
       end,
@@ -81,7 +82,11 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
       if Config.get([:instance, :profile_directory]) do
         "profile_directory"
       end,
-      "custom_emoji_reactions"
+      if Config.get([:translator, :enabled], false) do
+        "akkoma:machine_translation"
+      end,
+      "custom_emoji_reactions",
+      "pleroma:get:main/ostatus"
     ]
     |> Enum.filter(& &1)
   end
index 83914a2755ef3156de2de8858fadb4b3c0ff94eb..463d31d1ab7863c9648e124a29b89aea2536aaba 100644 (file)
@@ -17,7 +17,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
   alias Pleroma.Web.MastodonAPI.NotificationView
   alias Pleroma.Web.MastodonAPI.StatusView
 
-  @parent_types ~w{Like Announce EmojiReact}
+  defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
+
+  defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
+
+  @parent_types ~w{Like Announce EmojiReact Update}
 
   def render("index.json", %{notifications: notifications, for: reading_user} = opts) do
     activities = Enum.map(notifications, & &1.activity)
@@ -28,7 +32,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
         %{data: %{"type" => type}} ->
           type in @parent_types
       end)
-      |> Enum.map(& &1.data["object"])
+      |> Enum.map(&object_id_for/1)
       |> Activity.create_by_object_ap_id()
       |> Activity.with_preloaded_object(:left)
       |> Pleroma.Repo.all()
@@ -76,9 +80,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
 
     parent_activity_fn = fn ->
       if opts[:parent_activities] do
-        Activity.Queries.find_by_object_ap_id(opts[:parent_activities], activity.data["object"])
+        Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity))
       else
-        Activity.get_create_by_object_ap_id(activity.data["object"])
+        Activity.get_create_by_object_ap_id(object_id_for(activity))
       end
     end
 
@@ -107,6 +111,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
       "reblog" ->
         put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
 
+      "update" ->
+        put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
+
       "move" ->
         put_target(response, activity, reading_user, %{})
 
index f0fe9a4bae5bd9dcedd8b3c428c6855b5f588ef3..b3a35526e5dae6d6a90e8ca017dcd6871e524b14 100644 (file)
@@ -265,10 +265,30 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
 
     created_at = Utils.to_masto_date(object.data["published"])
 
+    edited_at =
+      with %{"updated" => updated} <- object.data,
+           date <- Utils.to_masto_date(updated),
+           true <- date != "" do
+        date
+      else
+        _ ->
+          nil
+      end
+
     reply_to = get_reply_to(activity, opts)
 
     reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
 
+    history_len =
+      1 +
+        (Object.Updater.history_for(object.data)
+         |> Map.get("orderedItems")
+         |> length())
+
+    # See render("history.json", ...) for more details
+    # Here the implicit index of the current content is 0
+    chrono_order = history_len - 1
+
     content =
       object
       |> render_content()
@@ -278,14 +298,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       |> Activity.HTML.get_cached_scrubbed_html_for_activity(
         User.html_filter_policy(opts[:for]),
         activity,
-        "mastoapi:content"
+        "mastoapi:content:#{chrono_order}"
       )
 
     content_plaintext =
       content
       |> Activity.HTML.get_cached_stripped_html_for_activity(
         activity,
-        "mastoapi:content"
+        "mastoapi:content:#{chrono_order}"
       )
 
     summary = object.data["summary"] || ""
@@ -353,8 +373,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       reblog: nil,
       card: card,
       content: content_html,
-      text: opts[:with_source] && object.data["source"],
+      text: opts[:with_source] && get_source_text(object.data["source"]),
       created_at: created_at,
+      edited_at: edited_at,
       reblogs_count: announcement_count,
       replies_count: object.data["repliesCount"] || 0,
       favourites_count: like_count,
@@ -375,6 +396,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       emojis: build_emojis(object.data["emoji"]),
       quote_id: if(quote, do: quote.id, else: nil),
       quote: maybe_render_quote(quote, opts),
+      emoji_reactions: emoji_reactions,
       pleroma: %{
         local: activity.local,
         conversation_id: get_context_id(activity),
@@ -399,6 +421,100 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
     nil
   end
 
+  def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
+    object = Object.normalize(activity, fetch: false)
+
+    hashtags = Object.hashtags(object)
+
+    user = CommonAPI.get_user(activity.data["actor"])
+
+    past_history =
+      Object.Updater.history_for(object.data)
+      |> Map.get("orderedItems")
+      |> Enum.map(&Map.put(&1, "id", object.data["id"]))
+      |> Enum.map(&%Object{data: &1, id: object.id})
+
+    history =
+      [object | past_history]
+      # Mastodon expects the original to be at the first
+      |> Enum.reverse()
+      |> Enum.with_index()
+      |> Enum.map(fn {object, chrono_order} ->
+        %{
+          # The history is prepended every time there is a new edit.
+          # In chrono_order, the oldest item is always at 0, and so on.
+          # The chrono_order is an invariant kept between edits.
+          chrono_order: chrono_order,
+          object: object
+        }
+      end)
+
+    individual_opts =
+      opts
+      |> Map.put(:as, :item)
+      |> Map.put(:user, user)
+      |> Map.put(:hashtags, hashtags)
+
+    render_many(history, StatusView, "history_item.json", individual_opts)
+  end
+
+  def render(
+        "history_item.json",
+        %{
+          activity: activity,
+          user: user,
+          item: %{object: object, chrono_order: chrono_order},
+          hashtags: hashtags
+        } = opts
+      ) do
+    sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
+
+    attachment_data = object.data["attachment"] || []
+    attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
+
+    created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
+
+    content =
+      object
+      |> render_content()
+
+    content_html =
+      content
+      |> Activity.HTML.get_cached_scrubbed_html_for_activity(
+        User.html_filter_policy(opts[:for]),
+        activity,
+        "mastoapi:content:#{chrono_order}"
+      )
+
+    summary = object.data["summary"] || ""
+
+    %{
+      account:
+        AccountView.render("show.json", %{
+          user: user,
+          for: opts[:for]
+        }),
+      content: content_html,
+      sensitive: sensitive,
+      spoiler_text: summary,
+      created_at: created_at,
+      media_attachments: attachments,
+      emojis: build_emojis(object.data["emoji"]),
+      poll: render(PollView, "show.json", object: object, for: opts[:for])
+    }
+  end
+
+  def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
+    object = Object.normalize(activity, fetch: false)
+
+    %{
+      id: activity.id,
+      text: get_source_text(Map.get(object.data, "source", "")),
+      spoiler_text: Map.get(object.data, "summary", ""),
+      content_type: get_source_content_type(object.data["source"])
+    }
+  end
+
   def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
     page_url_data = URI.parse(page_url)
 
@@ -451,10 +567,19 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
         true -> "unknown"
       end
 
-    <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
+    attachment_id =
+      with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
+           {_, %Object{data: _object_data, id: object_id}} <-
+             {:object, Object.get_by_ap_id(ap_id)} do
+        to_string(object_id)
+      else
+        _ ->
+          <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
+          to_string(attachment["id"] || hash_id)
+      end
 
     %{
-      id: to_string(attachment["id"] || hash_id),
+      id: attachment_id,
       url: href,
       remote_url: href,
       preview_url: href_preview,
@@ -586,10 +711,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
 
   defp build_emoji_map(emoji, users, url, current_user) do
     %{
-      name: emoji,
+      name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
       count: length(users),
       url: MediaProxy.url(url),
-      me: !!(current_user && current_user.ap_id in users)
+      me: !!(current_user && current_user.ap_id in users),
+      account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
     }
   end
 
@@ -621,15 +747,39 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
   defp maybe_render_quote(nil, _), do: nil
 
   defp maybe_render_quote(quote, opts) do
-    if opts[:do_not_recurse] || !visible_for_user?(quote, opts[:for]) do
-      nil
-    else
+    with %User{} = quoted_user <- User.get_cached_by_ap_id(quote.actor),
+         false <- Map.get(opts, :do_not_recurse, false),
+         true <- visible_for_user?(quote, opts[:for]),
+         false <- User.blocks?(opts[:for], quoted_user),
+         false <- User.mutes?(opts[:for], quoted_user) do
       opts =
         opts
         |> Map.put(:activity, quote)
         |> Map.put(:do_not_recurse, true)
 
       render("show.json", opts)
+    else
+      _ -> nil
     end
   end
+
+  defp get_source_text(%{"content" => content} = _source) do
+    content
+  end
+
+  defp get_source_text(source) when is_binary(source) do
+    source
+  end
+
+  defp get_source_text(_) do
+    ""
+  end
+
+  defp get_source_content_type(%{"mediaType" => type} = _source) do
+    type
+  end
+
+  defp get_source_content_type(_source) do
+    Utils.get_content_type(nil)
+  end
 end
index 861a7ce3eb5c7d893cd6b55b850a8e08beb7ef52..bd7c562432549c6e46258a8dfbd49e533f6a8078 100644 (file)
@@ -32,8 +32,15 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
           req
         end
 
-      {:cowboy_websocket, req, %{user: user, topic: topic, count: 0, timer: nil},
-       %{idle_timeout: @timeout}}
+      {:cowboy_websocket, req,
+       %{
+         user: user,
+         topic: topic,
+         count: 0,
+         timer: nil,
+         subscriptions: [],
+         oauth_token: oauth_token
+       }, %{idle_timeout: @timeout}}
     else
       {:error, :bad_topic} ->
         Logger.debug("#{__MODULE__} bad topic #{inspect(req)}")
@@ -52,7 +59,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
       "#{__MODULE__} accepted websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topic #{state.topic}"
     )
 
-    Streamer.add_socket(state.topic, state.user)
+    Streamer.add_socket(state.topic, state.oauth_token)
     {:ok, %{state | timer: timer()}}
   end
 
@@ -65,21 +72,50 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
   # We only receive pings for now
   def websocket_handle(:ping, state), do: {:ok, state}
 
-  def websocket_handle({:text, "ping"}, state) do
+  def websocket_handle({:text, ping}, state) when ping in ~w[ping PING] do
     if state.timer, do: Process.cancel_timer(state.timer)
     {:reply, {:text, "pong"}, %{state | timer: timer()}}
   end
 
+  def websocket_handle({:text, text}, state) do
+    with {:ok, json} <- Jason.decode(text) do
+      websocket_handle({:json, json}, state)
+    else
+      _ ->
+        Logger.error("#{__MODULE__} received text frame: #{text}")
+        {:ok, state}
+    end
+  end
+
+  def websocket_handle(
+        {:json, %{"type" => "subscribe", "stream" => stream_name}},
+        %{user: user, oauth_token: token} = state
+      ) do
+    with {:ok, topic} <- Streamer.get_topic(stream_name, user, token, %{}) do
+      new_subscriptions =
+        [topic | Map.get(state, :subscriptions, [])]
+        |> Enum.uniq()
+
+      {:ok, _topic} = Streamer.add_socket(topic, user)
+
+      {:ok, Map.put(state, :subscriptions, new_subscriptions)}
+    else
+      _ ->
+        Logger.error("#{__MODULE__} received invalid topic: #{stream_name}")
+        {:ok, state}
+    end
+  end
+
   def websocket_handle(frame, state) do
     Logger.error("#{__MODULE__} received frame: #{inspect(frame)}")
     {:ok, state}
   end
 
-  def websocket_info({:render_with_user, view, template, item}, state) do
+  def websocket_info({:render_with_user, view, template, item, topic}, state) do
     user = %User{} = User.get_cached_by_ap_id(state.user.ap_id)
 
     unless Streamer.filtered_by_user?(user, item) do
-      websocket_info({:text, view.render(template, item, user)}, %{state | user: user})
+      websocket_info({:text, view.render(template, item, user, topic)}, %{state | user: user})
     else
       {:ok, state}
     end
@@ -103,6 +139,10 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
     {:reply, :ping, %{state | timer: nil, count: 0}, :hibernate}
   end
 
+  def websocket_info(:close, state) do
+    {:stop, state}
+  end
+
   # State can be `[]` only in case we terminate before switching to websocket,
   # we already log errors for these cases in `init/1`, so just do nothing here
   def terminate(_reason, _req, []), do: :ok
index e0ecb0f4f7b986b86bc4b515a536ae2ae87e5d1a..e567041648691e8f0282825846e2bbf2cd061cc0 100644 (file)
@@ -94,4 +94,9 @@ defmodule Pleroma.Web.OAuth.Authorization do
     from(t in __MODULE__, where: t.app_id == ^app_id and t.token == ^token)
     |> Repo.find_resource()
   end
+
+  def get_preeexisting_by_app_and_user(%App{id: app_id} = _app, %User{id: user_id} = _user) do
+    from(t in __MODULE__, where: t.app_id == ^app_id and t.user_id == ^user_id, limit: 1)
+    |> Repo.find_resource()
+  end
 end
index 358120fe6c375bd98d8c7d056e59c0635da5b17e..455af11d720210994d64aedee4e486f22e29c10e 100644 (file)
@@ -59,18 +59,39 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   # after user already authorized to MastodonFE.
   # So we have to check client and token.
   def authorize(
-        %Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
+        %Plug.Conn{assigns: %{token: %Token{} = token, user: %User{} = user}} = 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
+      _ ->
+        maybe_reuse_token(conn, params, user.id)
+    end
+  end
+
+  def authorize(%Plug.Conn{} = conn, params) do
+    # if we have a user in the session, attempt to authenticate as them
+    # otherwise show the login form
+    maybe_reuse_token(conn, params, AuthHelper.get_session_user(conn))
+  end
+
+  defp maybe_reuse_token(conn, params, user_id) when is_binary(user_id) do
+    with %User{} = user <- User.get_cached_by_id(user_id),
+         %App{} = app <- Repo.get_by(App, client_id: params["client_id"]),
+         {:ok, %Token{} = token} <- Token.get_preeexisting_by_app_and_user(app, user),
+         {:ok, %Authorization{} = auth} <-
+           Authorization.get_preeexisting_by_app_and_user(app, user) do
+      conn
+      |> assign(:token, token)
+      |> after_create_authorization(auth, %{"authorization" => params})
     else
       _ -> do_authorize(conn, params)
     end
   end
 
-  def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params)
+  defp maybe_reuse_token(conn, params, _user), do: do_authorize(conn, params)
 
   defp do_authorize(%Plug.Conn{} = conn, params) do
     app = Repo.get_by(App, client_id: params["client_id"])
@@ -148,7 +169,9 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   def create_authorization(%Plug.Conn{} = conn, %{"authorization" => _} = params, opts) do
     with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
          {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
-      after_create_authorization(conn, auth, params)
+      conn
+      |> AuthHelper.put_session_user(user.id)
+      |> after_create_authorization(auth, params)
     else
       error ->
         handle_create_authorization_error(conn, error, params)
@@ -269,7 +292,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
          fixed_token = Token.Utils.fix_padding(params["code"]),
          {:ok, auth} <- Authorization.get_by_token(app, fixed_token),
          %User{} = user <- User.get_cached_by_id(auth.user_id),
-         {:ok, token} <- Token.exchange_token(app, auth) do
+         {:ok, token} <- Token.get_or_exchange_token(auth, app, user) do
       after_token_exchange(conn, %{user: user, token: token})
     else
       error ->
@@ -321,6 +344,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
   def after_token_exchange(%Plug.Conn{} = conn, %{token: token} = view_params) do
     conn
     |> AuthHelper.put_session_token(token.token)
+    |> AuthHelper.put_session_user(token.user_id)
     |> json(OAuthView.render("token.json", view_params))
   end
 
index 9d69e9db45374cab61fa1e9e56aa02cd13820b7a..c9398aeaa2be645a6b31d14383cf1ad28ada18df 100644 (file)
@@ -70,6 +70,16 @@ defmodule Pleroma.Web.OAuth.Token do
     end
   end
 
+  def get_preeexisting_by_app_and_user(app, user) do
+    Query.get_by_app(app.id)
+    |> Query.get_by_user(user.id)
+    |> Query.get_unexpired()
+    |> Query.preload([:user])
+    |> Query.sort_by_inserted_at()
+    |> Query.limit(1)
+    |> Repo.find_resource()
+  end
+
   defp put_token(changeset) do
     changeset
     |> change(%{token: Token.Utils.generate_token()})
@@ -86,6 +96,14 @@ defmodule Pleroma.Web.OAuth.Token do
     |> unique_constraint(:refresh_token)
   end
 
+  def get_or_exchange_token(%Authorization{} = auth, %App{} = app, %User{} = user) do
+    if auth.used do
+      get_preeexisting_by_app_and_user(app, user)
+    else
+      exchange_token(app, auth)
+    end
+  end
+
   defp put_valid_until(changeset, attrs) do
     valid_until =
       Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan()))
index d16a759d8a2d74a0995cd05cbe7e18a43d6f7364..662e7856d366a27aa9361a07316b6b11874baffb 100644 (file)
@@ -38,6 +38,19 @@ defmodule Pleroma.Web.OAuth.Token.Query do
     from(q in query, where: q.user_id == ^user_id)
   end
 
+  def get_unexpired(query) do
+    now = NaiveDateTime.utc_now()
+    from(q in query, where: q.valid_until > ^now)
+  end
+
+  def limit(query, limit) do
+    from(q in query, limit: ^limit)
+  end
+
+  def sort_by_inserted_at(query) do
+    from(q in query, order_by: [desc: :updated_at])
+  end
+
   @spec preload(query, any) :: query
   def preload(query \\ Token, assoc_preload \\ [])
 
index 8d65727041250ab099404ec15d51b02cee5647d8..de99bc137ef1e680f94a068be567719696865cc9 100644 (file)
@@ -21,6 +21,18 @@ defmodule Pleroma.Web.OAuth.Token.Strategy.Revoke do
   @doc "Revokes access token"
   @spec revoke(Token.t()) :: {:ok, Token.t()} | {:error, Ecto.Changeset.t()}
   def revoke(%Token{} = token) do
-    Repo.delete(token)
+    with {:ok, token} <- Repo.delete(token) do
+      Task.Supervisor.start_child(
+        Pleroma.TaskSupervisor,
+        Pleroma.Web.Streamer,
+        :close_streams_by_oauth_token,
+        [token],
+        restart: :transient
+      )
+
+      {:ok, token}
+    else
+      result -> result
+    end
   end
 end
index 1de02faf8fa4f2bca7f6804edaa013b68fe4d7e6..0933363a688171756aff96d4cceb35fdfaddf651 100644 (file)
@@ -74,6 +74,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
   defp filter(reactions, _), do: reactions
 
   def create(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do
+    emoji =
+      emoji
+      |> Pleroma.Emoji.fully_qualify_emoji()
+      |> Pleroma.Emoji.maybe_quote()
+
     with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji) do
       activity = Activity.get_by_id(activity_id)
 
@@ -84,6 +89,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do
   end
 
   def delete(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do
+    emoji =
+      emoji
+      |> Pleroma.Emoji.fully_qualify_emoji()
+      |> Pleroma.Emoji.maybe_quote()
+
     with {:ok, _activity} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji) do
       activity = Activity.get_by_id(activity_id)
 
index 9993480dbe224262d92e08f6a28779ca7d73d9a1..4335228b6b10ee00ed0c09438c68921bdeac8775 100644 (file)
@@ -8,6 +8,18 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionView do
   alias Pleroma.Web.MastodonAPI.AccountView
   alias Pleroma.Web.MediaProxy
 
+  def emoji_name(emoji, nil), do: emoji
+
+  def emoji_name(emoji, url) do
+    url = URI.parse(url)
+
+    if url.host == Pleroma.Web.Endpoint.host() do
+      emoji
+    else
+      "#{emoji}@#{url.host}"
+    end
+  end
+
   def render("index.json", %{emoji_reactions: emoji_reactions} = opts) do
     render_many(emoji_reactions, __MODULE__, "show.json", opts)
   end
@@ -16,7 +28,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionView do
     users = fetch_users(user_ap_ids)
 
     %{
-      name: emoji,
+      name: emoji_name(emoji, url),
       count: length(users),
       accounts: render(AccountView, "index.json", users: users, for: user),
       url: MediaProxy.url(url),
index cfee392c8109aee266596d854967878950cdd4dd..c906a4eecaee47ffbc4d4fa38f47afb129ea8cbe 100644 (file)
@@ -27,11 +27,11 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
     end
   end
 
-  def route_aliases(%{path_info: ["objects", id]}) do
+  def route_aliases(%{path_info: ["objects", id], query_string: query_string}) do
     ap_id = Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :object, id)
 
     with %Activity{} = activity <- Activity.get_by_object_ap_id_with_object(ap_id) do
-      ["/notice/#{activity.id}"]
+      ["/notice/#{activity.id}", "/notice/#{activity.id}?#{query_string}"]
     else
       _ -> []
     end
@@ -64,7 +64,9 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
     if has_signature_header?(conn) do
       # set (request-target) header to the appropriate value
       # we also replace the digest header with the one we computed
-      possible_paths = route_aliases(conn) ++ [conn.request_path]
+      possible_paths =
+        route_aliases(conn) ++ [conn.request_path, conn.request_path <> "?#{conn.query_string}"]
+
       assign_valid_signature_on_route_aliases(conn, possible_paths)
     else
       Logger.debug("No signature header!")
index a0310bbb50b535341181729c6a2717533138f00f..f722d94f72ca08d89d9fe81a42a3810d4a20f19e 100644 (file)
@@ -337,6 +337,7 @@ defmodule Pleroma.Web.Router do
     pipe_through(:pleroma_html)
 
     post("/main/ostatus", UtilController, :remote_subscribe)
+    get("/main/ostatus", UtilController, :show_subscribe_form)
     get("/ostatus_subscribe", RemoteFollowController, :follow)
     post("/ostatus_subscribe", RemoteFollowController, :do_follow)
   end
@@ -457,6 +458,16 @@ defmodule Pleroma.Web.Router do
     get("/federation_status", InstancesController, :show)
   end
 
+  scope "/api/v1", Pleroma.Web.PleromaAPI do
+    pipe_through(:authenticated_api)
+    put("/statuses/:id/emoji_reactions/:emoji", EmojiReactionController, :create)
+  end
+
+  scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do
+    pipe_through(:authenticated_api)
+    get("/translation/languages", TranslationController, :languages)
+  end
+
   scope "/api/v1", Pleroma.Web.MastodonAPI do
     pipe_through(:authenticated_api)
 
@@ -537,6 +548,7 @@ defmodule Pleroma.Web.Router do
     get("/bookmarks", StatusController, :bookmarks)
 
     post("/statuses", StatusController, :create)
+    put("/statuses/:id", StatusController, :update)
     delete("/statuses/:id", StatusController, :delete)
     post("/statuses/:id/reblog", StatusController, :reblog)
     post("/statuses/:id/unreblog", StatusController, :unreblog)
@@ -548,6 +560,7 @@ defmodule Pleroma.Web.Router do
     post("/statuses/:id/unbookmark", StatusController, :unbookmark)
     post("/statuses/:id/mute", StatusController, :mute_conversation)
     post("/statuses/:id/unmute", StatusController, :unmute_conversation)
+    get("/statuses/:id/translations/:language", StatusController, :translate)
 
     post("/push/subscription", SubscriptionController, :create)
     get("/push/subscription", SubscriptionController, :show)
@@ -601,6 +614,8 @@ defmodule Pleroma.Web.Router do
     get("/statuses/:id/context", StatusController, :context)
     get("/statuses/:id/favourited_by", StatusController, :favourited_by)
     get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
+    get("/statuses/:id/history", StatusController, :show_history)
+    get("/statuses/:id/source", StatusController, :show_source)
 
     get("/custom_emojis", CustomEmojiController, :index)
 
index 9a4ac1317dcf90d0ff8a425236832f5db50c783c..c03e7fc30a569844cf7032ce4a93e0e8bc7a4903 100644 (file)
@@ -36,7 +36,7 @@ defmodule Pleroma.Web.Streamer do
           {:ok, topic :: String.t()} | {:error, :bad_topic} | {:error, :unauthorized}
   def get_topic_and_add_socket(stream, user, oauth_token, params \\ %{}) do
     with {:ok, topic} <- get_topic(stream, user, oauth_token, params) do
-      add_socket(topic, user)
+      add_socket(topic, oauth_token)
     end
   end
 
@@ -114,15 +114,20 @@ defmodule Pleroma.Web.Streamer do
     {:error, :unauthorized}
   end
 
+  # mastodon multi-topic WS
+  def get_topic(nil, _user, _oauth_token, _params) do
+    {:ok, :multi}
+  end
+
   def get_topic(_stream, _user, _oauth_token, _params) do
     {:error, :bad_topic}
   end
 
   @doc "Registers the process for streaming. Use `get_topic/3` to get the full authorized topic."
-  def add_socket(topic, user) do
+  def add_socket(topic, oauth_token) do
     if should_env_send?() do
-      auth? = if user, do: true
-      Registry.register(@registry, topic, auth?)
+      oauth_token_id = if oauth_token, do: oauth_token.id, else: false
+      Registry.register(@registry, topic, oauth_token_id)
     end
 
     {:ok, topic}
@@ -186,8 +191,8 @@ defmodule Pleroma.Web.Streamer do
   end
 
   defp do_stream("follow_relationship", item) do
-    text = StreamerView.render("follow_relationships_update.json", item)
     user_topic = "user:#{item.follower.id}"
+    text = StreamerView.render("follow_relationships_update.json", item, user_topic)
 
     Logger.debug("Trying to push follow relationship update to #{user_topic}\n\n")
 
@@ -235,7 +240,7 @@ defmodule Pleroma.Web.Streamer do
        when topic in ["user", "user:notification"] do
     Registry.dispatch(@registry, "#{topic}:#{item.user_id}", fn list ->
       Enum.each(list, fn {pid, _auth} ->
-        send(pid, {:render_with_user, StreamerView, "notification.json", item})
+        send(pid, {:render_with_user, StreamerView, "notification.json", item, topic})
       end)
     end)
   end
@@ -259,7 +264,7 @@ defmodule Pleroma.Web.Streamer do
   end
 
   defp push_to_socket(topic, %Participation{} = participation) do
-    rendered = StreamerView.render("conversation.json", participation)
+    rendered = StreamerView.render("conversation.json", participation, topic)
 
     Registry.dispatch(@registry, topic, fn list ->
       Enum.each(list, fn {pid, _} ->
@@ -282,13 +287,34 @@ defmodule Pleroma.Web.Streamer do
 
   defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
 
+  defp push_to_socket(topic, %Activity{data: %{"type" => "Update"}} = item) do
+    create_activity =
+      Pleroma.Activity.get_create_by_object_ap_id(item.object.data["id"])
+      |> Map.put(:object, item.object)
+
+    anon_render = StreamerView.render("status_update.json", create_activity, topic)
+
+    Registry.dispatch(@registry, topic, fn list ->
+      Enum.each(list, fn {pid, auth?} ->
+        if auth? do
+          send(
+            pid,
+            {:render_with_user, StreamerView, "status_update.json", create_activity, topic}
+          )
+        else
+          send(pid, {:text, anon_render})
+        end
+      end)
+    end)
+  end
+
   defp push_to_socket(topic, item) do
-    anon_render = StreamerView.render("update.json", item)
+    anon_render = StreamerView.render("update.json", item, topic)
 
     Registry.dispatch(@registry, topic, fn list ->
       Enum.each(list, fn {pid, auth?} ->
         if auth? do
-          send(pid, {:render_with_user, StreamerView, "update.json", item})
+          send(pid, {:render_with_user, StreamerView, "update.json", item, topic})
         else
           send(pid, {:text, anon_render})
         end
@@ -306,6 +332,22 @@ defmodule Pleroma.Web.Streamer do
     end
   end
 
+  def close_streams_by_oauth_token(oauth_token) do
+    if should_env_send?() do
+      Registry.select(
+        @registry,
+        [
+          {
+            {:"$1", :"$2", :"$3"},
+            [{:==, :"$3", oauth_token.id}],
+            [:"$2"]
+          }
+        ]
+      )
+      |> Enum.each(fn pid -> send(pid, :close) end)
+    end
+  end
+
   # In test environement, only return true if the registry is started.
   # In benchmark environment, returns false.
   # In any other environment, always returns true.
diff --git a/lib/pleroma/web/templates/masto_fe/fedibird.index.html.eex b/lib/pleroma/web/templates/masto_fe/fedibird.index.html.eex
new file mode 100644 (file)
index 0000000..02c4218
--- /dev/null
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang='en'>
+<head>
+<meta charset='utf-8'>
+<meta content='width=device-width, initial-scale=1' name='viewport'>
+<title>
+<%= Config.get([:instance, :name]) %>
+</title>
+<link rel="icon" type="image/png" href="/favicon.png"/>
+<link rel="manifest" type="applicaton/manifest+json" href="<%= Routes.masto_fe_path(Pleroma.Web.Endpoint, :manifest) %>" />
+
+<meta name="theme-color" content="<%= Config.get([:manifest, :theme_color]) %>" />
+
+<script id='initial-state' type='application/json'><%= initial_state(@token, @user, @custom_emojis) %></script>
+
+<script crossorigin='anonymous' src="/packs/js/common.js"></script>
+<script crossorigin='anonymous' src="/packs/js/locale_en.js"></script>
+
+<link rel='preload' as='script' crossorigin='anonymous' href='/packs/js/features/getting_started.js'>
+<link rel='preload' as='script' crossorigin='anonymous' href='/packs/js/features/compose.js'>
+<link rel='preload' as='script' crossorigin='anonymous' href='/packs/js/features/home_timeline.js'>
+<link rel='preload' as='script' crossorigin='anonymous' href='/packs/js/features/notifications.js'>
+<script crossorigin='anonymous' src="/packs/js/application.js"></script>
+
+
+<link rel="stylesheet" media="all" href="/packs/css/common.css" />
+<link rel="stylesheet" media="all" href="/packs/css/default.css" />
+
+</head>
+<body class='app-body no-reduce-motion system-font'>
+  <div class='app-holder' data-props='{&quot;locale&quot;:&quot;en&quot;}' id='mastodon'>
+  </div>
+</body>
+</html>
diff --git a/lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex b/lib/pleroma/web/templates/twitter_api/util/status_interact.html.eex
new file mode 100644 (file)
index 0000000..d771749
--- /dev/null
@@ -0,0 +1,10 @@
+<%= if @error do %>
+  <h2><%= Gettext.dpgettext("static_pages", "status interact error", "Error: %{error}", error: @error) %></h2>
+<% else %>
+  <h2><%= raw Gettext.dpgettext("static_pages", "status interact header", "Interacting with %{nickname}'s %{status_link}", nickname: safe_to_string(html_escape(@nickname)), status_link: safe_to_string(link(Gettext.dpgettext("static_pages", "status interact header - status link text", "status"), to: @status_link))) %></h2>
+  <%= form_for @conn, Routes.util_path(@conn, :remote_subscribe), [as: "status"], fn f -> %>
+  <%= hidden_input f, :status_id, value: @status_id %>
+  <%= text_input f, :profile, placeholder: Gettext.dpgettext("static_pages", "placeholder text for account id", "Your account ID, e.g. lain@quitter.se") %>
+  <%= submit Gettext.dpgettext("static_pages", "status interact authorization button", "Interact") %>
+  <% end %>
+<% end %>
index b8abc666e90c6c2149aadf755bbd5a2dba75a776..a0c3e5c52907d50e237e9912d27433088e769f1c 100644 (file)
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
 
   require Logger
 
+  alias Pleroma.Activity
   alias Pleroma.Config
   alias Pleroma.Emoji
   alias Pleroma.Healthcheck
@@ -16,8 +17,16 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
   alias Pleroma.Web.Plugs.OAuthScopesPlug
   alias Pleroma.Web.WebFinger
 
-  plug(Pleroma.Web.ApiSpec.CastAndValidate when action != :remote_subscribe)
-  plug(Pleroma.Web.Plugs.FederatingPlug when action == :remote_subscribe)
+  plug(
+    Pleroma.Web.ApiSpec.CastAndValidate
+    when action != :remote_subscribe and action != :show_subscribe_form
+  )
+
+  plug(
+    Pleroma.Web.Plugs.FederatingPlug
+    when action == :remote_subscribe
+    when action == :show_subscribe_form
+  )
 
   plug(
     OAuthScopesPlug,
@@ -44,7 +53,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
 
   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TwitterUtilOperation
 
-  def remote_subscribe(conn, %{"nickname" => nick, "profile" => _}) do
+  def show_subscribe_form(conn, %{"nickname" => nick}) do
     with %User{} = user <- User.get_cached_by_nickname(nick),
          avatar = User.avatar_url(user) do
       conn
@@ -54,11 +63,52 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
         render(conn, "subscribe.html", %{
           nickname: nick,
           avatar: nil,
-          error: "Could not find user"
+          error:
+            Pleroma.Web.Gettext.dpgettext(
+              "static_pages",
+              "remote follow error message - user not found",
+              "Could not find user"
+            )
         })
     end
   end
 
+  def show_subscribe_form(conn, %{"status_id" => id}) do
+    with %Activity{} = activity <- Activity.get_by_id(id),
+         {:ok, ap_id} <- get_ap_id(activity),
+         %User{} = user <- User.get_cached_by_ap_id(activity.actor),
+         avatar = User.avatar_url(user) do
+      conn
+      |> render("status_interact.html", %{
+        status_link: ap_id,
+        status_id: id,
+        nickname: user.nickname,
+        avatar: avatar,
+        error: false
+      })
+    else
+      _e ->
+        render(conn, "status_interact.html", %{
+          status_id: id,
+          avatar: nil,
+          error:
+            Pleroma.Web.Gettext.dpgettext(
+              "static_pages",
+              "status interact error message - status not found",
+              "Could not find status"
+            )
+        })
+    end
+  end
+
+  def remote_subscribe(conn, %{"nickname" => nick, "profile" => _}) do
+    show_subscribe_form(conn, %{"nickname" => nick})
+  end
+
+  def remote_subscribe(conn, %{"status_id" => id, "profile" => _}) do
+    show_subscribe_form(conn, %{"status_id" => id})
+  end
+
   def remote_subscribe(conn, %{"user" => %{"nickname" => nick, "profile" => profile}}) do
     with {:ok, %{"subscribe_address" => template}} <- WebFinger.finger(profile),
          %User{ap_id: ap_id} <- User.get_cached_by_nickname(nick) do
@@ -69,7 +119,33 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
         render(conn, "subscribe.html", %{
           nickname: nick,
           avatar: nil,
-          error: "Something went wrong."
+          error:
+            Pleroma.Web.Gettext.dpgettext(
+              "static_pages",
+              "remote follow error message - unknown error",
+              "Something went wrong."
+            )
+        })
+    end
+  end
+
+  def remote_subscribe(conn, %{"status" => %{"status_id" => id, "profile" => profile}}) do
+    with {:ok, %{"subscribe_address" => template}} <- WebFinger.finger(profile),
+         %Activity{} = activity <- Activity.get_by_id(id),
+         {:ok, ap_id} <- get_ap_id(activity) do
+      conn
+      |> Phoenix.Controller.redirect(external: String.replace(template, "{uri}", ap_id))
+    else
+      _e ->
+        render(conn, "status_interact.html", %{
+          status_id: id,
+          avatar: nil,
+          error:
+            Pleroma.Web.Gettext.dpgettext(
+              "static_pages",
+              "status interact error message - unknown error",
+              "Something went wrong."
+            )
         })
     end
   end
@@ -83,6 +159,15 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
     end
   end
 
+  defp get_ap_id(activity) do
+    object = Pleroma.Object.normalize(activity, fetch: false)
+
+    case object do
+      %{data: %{"id" => ap_id}} -> {:ok, ap_id}
+      _ -> {:no_ap_id, nil}
+    end
+  end
+
   def frontend_configurations(conn, _params) do
     render(conn, "frontend_configurations.json")
   end
index a03020290b0129b9f1aa885e1a450284c2c68d2e..6ed74ee80013cdf5d67c968eb5e55a56e0ddd1a5 100644 (file)
@@ -4,7 +4,9 @@
 
 defmodule Pleroma.Web.TwitterAPI.UtilView do
   use Pleroma.Web, :view
+  import Phoenix.HTML
   import Phoenix.HTML.Form
+  import Phoenix.HTML.Link
   alias Pleroma.Config
   alias Pleroma.Web.Endpoint
   alias Pleroma.Web.Gettext
index 63a9c8179ea268b3955039a08da9f6184276a089..305368c9d914ae110ee397e2ebe387cc9886d2b3 100644 (file)
@@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastoFEView do
 
     %{
       meta: %{
+        title: Config.get([:instance, :name]),
         streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
         access_token: token,
         locale: "en",
@@ -27,7 +28,11 @@ defmodule Pleroma.Web.MastoFEView do
         display_sensitive_media: false,
         reduce_motion: false,
         max_toot_chars: limit,
-        mascot: User.get_mascot(user)["url"]
+        mascot: User.get_mascot(user)["url"],
+        show_quote_button: true,
+        enable_reaction: true,
+        compact_reaction: false,
+        advanced_layout: true
       },
       poll_limits: Config.get([:instance, :poll_limits]),
       rights: %{
@@ -56,6 +61,7 @@ defmodule Pleroma.Web.MastoFEView do
           "video\/mp4"
         ]
       },
+      lists: [],
       settings: user.mastofe_settings || %{},
       push_subscription: nil,
       accounts: %{user.id => render(AccountView, "show.json", user: user, for: user)},
index de2e4d1e984dfc6bcaf0158305b7a17d4621bedb..eba3d96ec19f59cfc43973d387858d57cf69368a 100644 (file)
@@ -11,8 +11,9 @@ defmodule Pleroma.Web.StreamerView do
   alias Pleroma.User
   alias Pleroma.Web.MastodonAPI.NotificationView
 
-  def render("update.json", %Activity{} = activity, %User{} = user) do
+  def render("update.json", %Activity{} = activity, %User{} = user, topic) do
     %{
+      stream: [topic],
       event: "update",
       payload:
         Pleroma.Web.MastodonAPI.StatusView.render(
@@ -25,8 +26,26 @@ defmodule Pleroma.Web.StreamerView do
     |> Jason.encode!()
   end
 
-  def render("notification.json", %Notification{} = notify, %User{} = user) do
+  def render("status_update.json", %Activity{} = activity, %User{} = user, topic) do
+    activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+    %{
+      stream: [topic],
+      event: "status.update",
+      payload:
+        Pleroma.Web.MastodonAPI.StatusView.render(
+          "show.json",
+          activity: activity,
+          for: user
+        )
+        |> Jason.encode!()
+    }
+    |> Jason.encode!()
+  end
+
+  def render("notification.json", %Notification{} = notify, %User{} = user, topic) do
     %{
+      stream: [topic],
       event: "notification",
       payload:
         NotificationView.render(
@@ -38,8 +57,9 @@ defmodule Pleroma.Web.StreamerView do
     |> Jason.encode!()
   end
 
-  def render("update.json", %Activity{} = activity) do
+  def render("update.json", %Activity{} = activity, topic) do
     %{
+      stream: [topic],
       event: "update",
       payload:
         Pleroma.Web.MastodonAPI.StatusView.render(
@@ -51,8 +71,25 @@ defmodule Pleroma.Web.StreamerView do
     |> Jason.encode!()
   end
 
-  def render("follow_relationships_update.json", item) do
+  def render("status_update.json", %Activity{} = activity, topic) do
+    activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+    %{
+      stream: [topic],
+      event: "status.update",
+      payload:
+        Pleroma.Web.MastodonAPI.StatusView.render(
+          "show.json",
+          activity: activity
+        )
+        |> Jason.encode!()
+    }
+    |> Jason.encode!()
+  end
+
+  def render("follow_relationships_update.json", item, topic) do
     %{
+      stream: [topic],
       event: "pleroma:follow_relationships_update",
       payload:
         %{
@@ -73,8 +110,9 @@ defmodule Pleroma.Web.StreamerView do
     |> Jason.encode!()
   end
 
-  def render("conversation.json", %Participation{} = participation) do
+  def render("conversation.json", %Participation{} = participation, topic) do
     %{
+      stream: [topic],
       event: "conversation",
       payload:
         Pleroma.Web.MastodonAPI.ConversationView.render("participation.json", %{
diff --git a/mix.exs b/mix.exs
index c6bd0e28f81abdf587b2b70d780c48153a557bbe..19e6fd045684ab36d7140d5334ba797570472ca1 100644 (file)
--- a/mix.exs
+++ b/mix.exs
@@ -4,8 +4,8 @@ defmodule Pleroma.Mixfile do
   def project do
     [
       app: :pleroma,
-      version: version("3.1.0"),
-      elixir: "~> 1.9",
+      version: version("3.2.0"),
+      elixir: "~> 1.12",
       elixirc_paths: elixirc_paths(Mix.env()),
       compilers: [:phoenix, :gettext] ++ Mix.compilers(),
       elixirc_options: [warnings_as_errors: warnings_as_errors()],
@@ -129,7 +129,7 @@ defmodule Pleroma.Mixfile do
        override: true},
       {:bcrypt_elixir, "~> 2.2"},
       {:trailing_format_plug, "~> 0.0.7"},
-      {:fast_sanitize, "~> 0.2.0"},
+      {:fast_sanitize, "~> 0.2.3"},
       {:html_entities, "~> 0.5", override: true},
       {:phoenix_html, "~> 3.1", override: true},
       {:calendar, "~> 1.0"},
@@ -191,6 +191,9 @@ defmodule Pleroma.Mixfile do
       {:ecto_psql_extras, "~> 0.6"},
       {:elasticsearch,
        git: "https://akkoma.dev/AkkomaGang/elasticsearch-elixir.git", ref: "main"},
+      {:mfm_parser,
+       git: "https://akkoma.dev/AkkomaGang/mfm-parser.git",
+       ref: "912fba81152d4d572e457fd5427f9875b2bc3dbe"},
 
       # indirect dependency version override
       {:plug, "~> 1.10.4", override: true},
@@ -203,7 +206,7 @@ defmodule Pleroma.Mixfile do
       # temporary downgrade for excoveralls, hackney until hackney max_connections bug will be fixed
       {:excoveralls, "0.12.3", only: :test},
       {:mox, "~> 1.0", only: :test},
-      {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test}
+      {:websockex, "~> 0.4.3", only: :test}
     ] ++ oauth_deps()
   end
 
index 2d3c9f33e2adb0a77ea2d85ed15a0d7b9d522d96..7eeb5c138ecc1d2a310b2d7a80ac3e2446a23ce9 100644 (file)
--- a/mix.lock
+++ b/mix.lock
@@ -67,6 +67,7 @@
   "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
   "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"},
   "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
+  "mfm_parser": {:git, "https://akkoma.dev/AkkomaGang/mfm-parser.git", "912fba81152d4d572e457fd5427f9875b2bc3dbe", [ref: "912fba81152d4d572e457fd5427f9875b2bc3dbe"]},
   "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
   "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
   "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
   "table_rex": {:hex, :table_rex, "3.1.1", "0c67164d1714b5e806d5067c1e96ff098ba7ae79413cc075973e17c38a587caa", [:mix], [], "hexpm", "678a23aba4d670419c23c17790f9dcd635a4a89022040df7d5d772cb21012490"},
   "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
   "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
+  "temple": {:git, "https://akkoma.dev/AkkomaGang/temple.git", "066a699ade472d8fa42a9d730b29a61af9bc8b59", [ref: "066a699ade472d8fa42a9d730b29a61af9bc8b59"]},
   "tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"},
   "timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"},
   "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"},
   "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"},
   "vex": {:hex, :vex, "0.9.0", "613ea5eb3055662e7178b83e25b2df0975f68c3d8bb67c1645f0573e1a78d606", [:mix], [], "hexpm", "c69fff44d5c8aa3f1faee71bba1dcab05dd36364c5a629df8bb11751240c857f"},
   "web_push_encryption": {:hex, :web_push_encryption, "0.3.1", "76d0e7375142dfee67391e7690e89f92578889cbcf2879377900b5620ee4708d", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.11.1", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "4f82b2e57622fb9337559058e8797cb0df7e7c9790793bdc4e40bc895f70e2a2"},
-  "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []},
+  "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"},
 }
diff --git a/priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs b/priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs
new file mode 100644 (file)
index 0000000..0656c88
--- /dev/null
@@ -0,0 +1,51 @@
+defmodule Pleroma.Repo.Migrations.AddUpdateToNotificationsEnum do
+  use Ecto.Migration
+
+  @disable_ddl_transaction true
+
+  def up do
+    """
+    alter type notification_type add value 'update'
+    """
+    |> execute()
+  end
+
+  # 20210717000000_add_poll_to_notifications_enum.exs
+  def down do
+    alter table(:notifications) do
+      modify(:type, :string)
+    end
+
+    """
+    delete from notifications where type = 'update'
+    """
+    |> execute()
+
+    """
+    drop type if exists notification_type
+    """
+    |> execute()
+
+    """
+    create type notification_type as enum (
+      'follow',
+      'follow_request',
+      'mention',
+      'move',
+      'pleroma:emoji_reaction',
+      'pleroma:chat_mention',
+      'reblog',
+      'favourite',
+      'pleroma:report',
+      'poll'
+    )
+    """
+    |> execute()
+
+    """
+    alter table notifications
+    alter column type type notification_type using (type::notification_type)
+    """
+    |> execute()
+  end
+end
diff --git a/priv/repo/migrations/20220831170605_remove_local_cancelled_follows.exs b/priv/repo/migrations/20220831170605_remove_local_cancelled_follows.exs
new file mode 100644 (file)
index 0000000..16597f8
--- /dev/null
@@ -0,0 +1,22 @@
+defmodule Pleroma.Repo.Migrations.RemoveLocalCancelledFollows do
+  use Ecto.Migration
+
+  def up do
+    statement = """
+    DELETE FROM
+        activities
+    WHERE
+        (data->>'type') = 'Follow'
+    AND
+        (data->>'state') = 'cancelled'
+    AND
+        local = true;
+    """
+
+    execute(statement)
+  end
+
+  def down do
+    :ok
+  end
+end
index 153b0be453499aea33f4c9394daaf0183df6aae3..950b6c21ee77dcb23520c48a74127ce027c23cc8 100644 (file)
@@ -56,8 +56,36 @@ defmodule Pleroma.HTML.Scrubber.Default do
   Meta.allow_tag_with_these_attributes(:u, [])
   Meta.allow_tag_with_these_attributes(:ul, [])
 
-  Meta.allow_tag_with_this_attribute_values(:span, "class", ["h-card", "quote-inline"])
-  Meta.allow_tag_with_these_attributes(:span, [])
+  Meta.allow_tags_with_style_attributes([:span])
+
+  Meta.allow_tag_with_this_attribute_values(:span, "class", [
+    "h-card",
+    "quote-inline",
+    "mfm",
+    "mfm _mfm_tada_",
+    "mfm _mfm_jelly_",
+    "mfm _mfm_twitch_",
+    "mfm _mfm_shake_",
+    "mfm _mfm_spin_",
+    "mfm _mfm_jump_",
+    "mfm _mfm_bounce_",
+    "mfm _mfm_flip_",
+    "mfm _mfm_x2_",
+    "mfm _mfm_x3_",
+    "mfm _mfm_x4_",
+    "mfm _mfm_blur_",
+    "mfm _mfm_rainbow_",
+    "mfm _mfm_rotate_"
+  ])
+
+  Meta.allow_tag_with_these_attributes(:span, [
+    "data-x",
+    "data-y",
+    "data-h",
+    "data-v",
+    "data-left",
+    "data-right"
+  ])
 
   Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"])
 
@@ -101,4 +129,6 @@ defmodule Pleroma.HTML.Scrubber.Default do
   Meta.allow_tag_with_these_attributes(:small, [])
 
   Meta.strip_everything_not_covered()
+
+  defp scrub_css(value), do: value
 end
index 4a304f5760025cb9b9899b5b2ab053e35a4975ff..e60d31966ac80bf0517da768071ae1a95cc2c4c3 100644 (file)
@@ -6,7 +6,18 @@
   <body>
     <h3>Welcome to Akkoma!</h3>
     <p>If you're seeing this page, your server works!</p>
-    <p>In order to get a frontend to show here, you'll need to set up <code>:pleroma, :frontends, primary</code> and install your frontend of choice</p>
-    <a href="https://docs.akkoma.dev/stable/configuration/cheatsheet/#frontend-management">Documentation</a>
+    <p>In order to get a frontend to show here, you'll need to set up <code>:pleroma, :frontends, primary</code> and install your frontend of choice, in most cases this will just be:</p>
+    <pre>
+        <code lang="bash">
+        # OTP
+        ./bin/pleroma_ctl frontend install pleroma-fe --ref stable
+        # Source
+        mix pleroma.frontend install pleroma-fe --ref stable
+
+        ## you can do the same thing for admin-fe if you so wish
+        </code>
+    </pre>
+    <p><a href="https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/">Installation Command Documentation</a></p>
+    <p><a href="https://docs.akkoma.dev/stable/configuration/cheatsheet/#frontend-management">Config Documentation</a></p>
   </body>
 </html>
index d2b62ba775967532a781a749e8fbfb385a7ebb8a..f582ed42c263ea651351f2de895dae330cadc0e4 100644 (file)
@@ -39,7 +39,9 @@
             "alsoKnownAs": {
                 "@id": "as:alsoKnownAs",
                 "@type": "@id"
-            }
+            },
+            "vcard": "http://www.w3.org/2006/vcard/ns#",
+            "formerRepresentations": "litepub:formerRepresentations"
         }
     ]
 }
similarity index 63%
rename from rel/files/installation/pleroma.service
rename to rel/files/installation/akkoma.service
index e47cf58dc3a3f464bd797ccdda8be62d1c09f05d..0719dbf7301e0b492b70e2bdacf2d18a68011138 100644 (file)
@@ -1,27 +1,27 @@
 [Unit]
-Description=Pleroma social network
+Description=Akkoma social network
 After=network.target postgresql.service nginx.service
 
 [Service]
 KillMode=process
 Restart=on-failure
 
-; Name of the user that runs the Pleroma service.
-User=pleroma
+; Name of the user that runs the Akkoma service.
+User=akkoma
 
 ; Make sure that all paths fit your installation.
-; Path to the home directory of the user running the Pleroma service.
-Environment="HOME=/opt/pleroma"
-; Path to the folder containing the Pleroma installation.
-WorkingDirectory=/opt/pleroma
-; Path to the Pleroma binary.
-ExecStart=/opt/pleroma/bin/pleroma start
-ExecStop=/opt/pleroma/bin/pleroma stop
+; Path to the home directory of the user running the Akkoma service.
+Environment="HOME=/opt/akkoma"
+; Path to the folder containing the Akkoma installation.
+WorkingDirectory=/opt/akkoma
+; Path to the Mix binary.
+ExecStart=/opt/akkoma/bin/pleroma start
+ExecStop=/opt/akkoma/bin/pleroma stop
 
 ; Some security directives.
 ; Use private /tmp and /var/tmp folders inside a new file system namespace, which are discarded after the process stops.
 PrivateTmp=true
-; The /home, /root, and /run/user folders can not be accessed by this service anymore. If your Pleroma user has its home folder in one of the restricted places, or use one of these folders as its working directory, you have to set this to false.
+; The /home, /root, and /run/user folders can not be accessed by this service anymore. If your Akkoma user has its home folder in one of the restricted places, or use one of these folders as its working directory, you have to set this to false.
 ProtectHome=true
 ; Mount /usr, /boot, and /etc as read-only for processes invoked by this service.
 ProtectSystem=full
similarity index 70%
rename from rel/files/installation/init.d/pleroma
rename to rel/files/installation/init.d/akkoma
index dea1db26c8e25e87e99ce464b6b8243bc657cd56..ea6ea35807721cf7609ba313d4d1e75a4331c1ce 100755 (executable)
@@ -3,17 +3,17 @@
 supervisor=supervise-daemon
 
 # Requires OpenRC >= 0.35
-directory=/opt/pleroma
+directory=/opt/akkoma
 
-command=/opt/pleroma/bin/pleroma
+command=/opt/akkoma/bin/pleroma
 command_args="start"
-command_user=pleroma
+command_user=akkoma
 command_background=1
 
 # Ask process to terminate within 30 seconds, otherwise kill it
 retry="SIGTERM/30/SIGKILL/5"
 
-pidfile="/var/run/pleroma.pid"
+pidfile="/var/run/akkoma.pid"
 
 depend() {
     want nginx
index 21aae9204ccc99cb6202dc840b6ed548c5784d12..590e399fe9689ce1865d5d82f738a3992bf9ed16 100644 (file)
@@ -3,7 +3,7 @@
   "type": "Note",
   "attributedTo": "https://misskey.local.live/users/92hzkskwgy",
   "summary": null,
-  "content": "this gets replaced",
+  "content": "this does not get replaced",
   "source": {
     "content": "@akkoma_user @remote_user @full_tag_remote_user@misskey.local.live @oops_not_a_mention linkifylink #dancedance $[jelly mfm goes here] \n\n## aaa",
     "mediaType": "text/x.misskeymarkdown"
index db75b363009778eb20ee0b7f4fd2d23881c16ff6..d456904183759bf0f44d76d051e96affe3f26f35 100644 (file)
@@ -65,7 +65,7 @@ defmodule Mix.Tasks.Pleroma.RelayTest do
       Mix.Tasks.Pleroma.Relay.run(["unfollow", target_instance])
 
       cancelled_activity = Activity.get_by_ap_id(follow_activity.data["id"])
-      assert cancelled_activity.data["state"] == "cancelled"
+      assert is_nil(cancelled_activity)
 
       [undo_activity] =
         ActivityPub.fetch_activities([], %{
@@ -78,7 +78,6 @@ defmodule Mix.Tasks.Pleroma.RelayTest do
 
       assert undo_activity.data["type"] == "Undo"
       assert undo_activity.data["actor"] == local_user.ap_id
-      assert undo_activity.data["object"]["id"] == cancelled_activity.data["id"]
       refute "#{target_instance}/followers" in User.following(local_user)
     end
 
@@ -142,7 +141,7 @@ defmodule Mix.Tasks.Pleroma.RelayTest do
       Mix.Tasks.Pleroma.Relay.run(["unfollow", target_instance, "--force"])
 
       cancelled_activity = Activity.get_by_ap_id(follow_activity.data["id"])
-      assert cancelled_activity.data["state"] == "cancelled"
+      assert is_nil(cancelled_activity)
 
       [undo_activity] =
         ActivityPub.fetch_activities(
@@ -152,7 +151,6 @@ defmodule Mix.Tasks.Pleroma.RelayTest do
 
       assert undo_activity.data["type"] == "Undo"
       assert undo_activity.data["actor"] == local_user.ap_id
-      assert undo_activity.data["object"]["id"] == cancelled_activity.data["id"]
       refute "#{target_instance}/followers" in User.following(local_user)
     end
   end
index b9f84f5c40551190b391a5130cc620c668bc4e5e..7a582a3d79a759b991b5e7c068aa751dd72c74d1 100644 (file)
@@ -30,7 +30,7 @@ defmodule Akkoma.Collections.FetcherTest do
         }
     end)
 
-    {:ok, objects} = Fetcher.fetch_collection_by_ap_id(ap_id)
+    {:ok, objects} = Fetcher.fetch_collection(ap_id)
     assert [%{"type" => "Create"}, %{"type" => "Like"}] = objects
   end
 
@@ -53,7 +53,7 @@ defmodule Akkoma.Collections.FetcherTest do
         }
     end)
 
-    {:ok, objects} = Fetcher.fetch_collection_by_ap_id(ap_id)
+    {:ok, objects} = Fetcher.fetch_collection(ap_id)
     assert [%{"type" => "Create"}, %{"type" => "Like"}] = objects
   end
 
@@ -106,7 +106,7 @@ defmodule Akkoma.Collections.FetcherTest do
         }
     end)
 
-    {:ok, objects} = Fetcher.fetch_collection_by_ap_id(ap_id)
+    {:ok, objects} = Fetcher.fetch_collection(ap_id)
     assert [%{"type" => "Create"}, %{"type" => "Like"}] = objects
   end
 
@@ -161,7 +161,58 @@ defmodule Akkoma.Collections.FetcherTest do
         }
     end)
 
-    {:ok, objects} = Fetcher.fetch_collection_by_ap_id(ap_id)
+    {:ok, objects} = Fetcher.fetch_collection(ap_id)
+    assert [%{"type" => "Create"}] = objects
+  end
+
+  test "it should stop fetching when we hit a 404" do
+    clear_config([:activitypub, :max_collection_objects], 1)
+
+    unordered_collection =
+      "test/fixtures/collections/unordered_page_reference.json"
+      |> File.read!()
+
+    first_page =
+      "test/fixtures/collections/unordered_page_first.json"
+      |> File.read!()
+
+    ap_id = "https://example.com/collection/unordered_page_reference"
+    first_page_id = "https://example.com/collection/unordered_page_reference?page=1"
+    second_page_id = "https://example.com/collection/unordered_page_reference?page=2"
+
+    Tesla.Mock.mock(fn
+      %{
+        method: :get,
+        url: ^ap_id
+      } ->
+        %Tesla.Env{
+          status: 200,
+          body: unordered_collection,
+          headers: [{"content-type", "application/activity+json"}]
+        }
+
+      %{
+        method: :get,
+        url: ^first_page_id
+      } ->
+        %Tesla.Env{
+          status: 200,
+          body: first_page,
+          headers: [{"content-type", "application/activity+json"}]
+        }
+
+      %{
+        method: :get,
+        url: ^second_page_id
+      } ->
+        %Tesla.Env{
+          status: 404,
+          body: nil,
+          headers: [{"content-type", "application/activity+json"}]
+        }
+    end)
+
+    {:ok, objects} = Fetcher.fetch_collection(ap_id)
     assert [%{"type" => "Create"}] = objects
   end
 end
index c56f20ec55b6b66fbf01c858551073e99bc7146b..30cb92fa7e746f56963535527193b95a2101c553 100644 (file)
@@ -10,13 +10,16 @@ defmodule Pleroma.Config.TransferTaskTest do
 
   alias Pleroma.Config.TransferTask
 
-  setup do: clear_config(:configurable_from_database, true)
+  setup do
+    clear_config(:configurable_from_database, true)
+  end
 
   test "transfer config values from db to env" do
     refute Application.get_env(:pleroma, :test_key)
     refute Application.get_env(:idna, :test_key)
     refute Application.get_env(:quack, :test_key)
     refute Application.get_env(:postgrex, :test_key)
+
     initial = Application.get_env(:logger, :level)
 
     insert(:config, key: :test_key, value: [live: 2, com: 3])
@@ -24,7 +27,7 @@ defmodule Pleroma.Config.TransferTaskTest do
     insert(:config, group: :quack, key: :test_key, value: [:test_value1, :test_value2])
     insert(:config, group: :postgrex, key: :test_key, value: :value)
     insert(:config, group: :logger, key: :level, value: :debug)
-
+    insert(:config, group: :pleroma, key: :instance, value: [static_dir: "static_dir_from_db"])
     TransferTask.start_link([])
 
     assert Application.get_env(:pleroma, :test_key) == [live: 2, com: 3]
@@ -32,6 +35,7 @@ defmodule Pleroma.Config.TransferTaskTest do
     assert Application.get_env(:quack, :test_key) == [:test_value1, :test_value2]
     assert Application.get_env(:logger, :level) == :debug
     assert Application.get_env(:postgrex, :test_key) == :value
+    assert Application.get_env(:pleroma, :instance)[:static_dir] == "static_dir_from_db"
 
     on_exit(fn ->
       Application.delete_env(:pleroma, :test_key)
@@ -39,6 +43,42 @@ defmodule Pleroma.Config.TransferTaskTest do
       Application.delete_env(:quack, :test_key)
       Application.delete_env(:postgrex, :test_key)
       Application.put_env(:logger, :level, initial)
+      System.delete_env("RELEASE_NAME")
+    end)
+  end
+
+  test "transfer task falls back to env before default" do
+    instance = Application.get_env(:pleroma, :instance)
+
+    insert(:config, key: :instance, value: [name: "wow"])
+    clear_config([:instance, :static_dir], "static_dir_from_env")
+    TransferTask.start_link([])
+
+    assert Application.get_env(:pleroma, :instance)[:name] == "wow"
+    assert Application.get_env(:pleroma, :instance)[:static_dir] == "static_dir_from_env"
+
+    on_exit(fn ->
+      Application.put_env(:pleroma, :instance, instance)
+    end)
+  end
+
+  test "transfer task falls back to release defaults if no other values found" do
+    instance = Application.get_env(:pleroma, :instance)
+
+    System.put_env("RELEASE_NAME", "akkoma")
+    Pleroma.Config.Holder.save_default()
+    insert(:config, key: :instance, value: [name: "wow"])
+    Application.delete_env(:pleroma, :instance)
+
+    TransferTask.start_link([])
+
+    assert Application.get_env(:pleroma, :instance)[:name] == "wow"
+    assert Application.get_env(:pleroma, :instance)[:static_dir] == "/var/lib/akkoma/static"
+
+    on_exit(fn ->
+      System.delete_env("RELEASE_NAME")
+      Pleroma.Config.Holder.save_default()
+      Application.put_env(:pleroma, :instance, instance)
     end)
   end
 
index 43ec57893c256f76986c00397f74cd3e88d2be03..9e266868dc52aeefa47df49f9d3a77f4cb10e8de 100644 (file)
@@ -31,18 +31,23 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
     WebsocketClient.start_link(self(), path, headers)
   end
 
-  test "refuses invalid requests" do
+  test "allows multi-streams" do
     capture_log(fn ->
-      assert {:error, {404, _}} = start_socket()
-      assert {:error, {404, _}} = start_socket("?stream=ncjdk")
+      assert {:ok, _} = start_socket()
+
+      assert {:error, %WebSockex.RequestError{code: 404, message: "Not Found"}} =
+               start_socket("?stream=ncjdk")
+
       Process.sleep(30)
     end)
   end
 
   test "requires authentication and a valid token for protected streams" do
     capture_log(fn ->
-      assert {:error, {401, _}} = start_socket("?stream=user&access_token=aaaaaaaaaaaa")
-      assert {:error, {401, _}} = start_socket("?stream=user")
+      assert {:error, %WebSockex.RequestError{code: 401}} =
+               start_socket("?stream=user&access_token=aaaaaaaaaaaa")
+
+      assert {:error, %WebSockex.RequestError{code: 401}} = start_socket("?stream=user")
       Process.sleep(30)
     end)
   end
@@ -91,7 +96,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
 
       {:ok, token} = OAuth.Token.exchange_token(app, auth)
 
-      %{user: user, token: token}
+      %{app: app, user: user, token: token}
     end
 
     test "accepts valid tokens", state do
@@ -102,7 +107,7 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
       assert {:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
 
       capture_log(fn ->
-        assert {:error, {401, _}} = start_socket("?stream=user")
+        assert {:error, %WebSockex.RequestError{code: 401}} = start_socket("?stream=user")
         Process.sleep(30)
       end)
     end
@@ -111,7 +116,9 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
       assert {:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}")
 
       capture_log(fn ->
-        assert {:error, {401, _}} = start_socket("?stream=user:notification")
+        assert {:error, %WebSockex.RequestError{code: 401}} =
+                 start_socket("?stream=user:notification")
+
         Process.sleep(30)
       end)
     end
@@ -120,11 +127,27 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
       assert {:ok, _} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", token.token}])
 
       capture_log(fn ->
-        assert {:error, {401, _}} =
+        assert {:error, %WebSockex.RequestError{code: 401}} =
                  start_socket("?stream=user", [{"Sec-WebSocket-Protocol", "I am a friend"}])
 
         Process.sleep(30)
       end)
     end
+
+    test "disconnect when token is revoked", %{app: app, user: user, token: token} do
+      assert {:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}")
+      assert {:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
+
+      {:ok, auth} = OAuth.Authorization.create_authorization(app, user)
+
+      {:ok, token2} = OAuth.Token.exchange_token(app, auth)
+      assert {:ok, _} = start_socket("?stream=user&access_token=#{token2.token}")
+
+      OAuth.Token.Strategy.Revoke.revoke(token)
+
+      assert_receive {:close, _}
+      assert_receive {:close, _}
+      refute_receive {:close, _}
+    end
   end
 end
index b47edd0a3604b37cd6bdb2d0e97ac6d774d9ef58..68330465b9eb007a1024063e28f8e2f65b3e3993 100644 (file)
@@ -127,6 +127,28 @@ defmodule Pleroma.NotificationTest do
       subscriber_notifications = Notification.for_user(subscriber)
       assert Enum.empty?(subscriber_notifications)
     end
+
+    test "it sends edited notifications to those who repeated a status" do
+      user = insert(:user)
+      repeated_user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, activity_one} =
+        CommonAPI.post(user, %{
+          status: "hey @#{other_user.nickname}!"
+        })
+
+      {:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user)
+
+      {:ok, _edit_activity} =
+        CommonAPI.update(user, activity_one, %{
+          status: "hey @#{other_user.nickname}! mew mew"
+        })
+
+      assert [%{type: "reblog"}] = Notification.for_user(user)
+      assert [%{type: "update"}] = Notification.for_user(repeated_user)
+      assert [%{type: "mention"}] = Notification.for_user(other_user)
+    end
   end
 
   test "create_poll_notifications/1" do
@@ -224,7 +246,7 @@ defmodule Pleroma.NotificationTest do
       task =
         Task.async(fn ->
           {:ok, _topic} = Streamer.get_topic_and_add_socket("user", user, oauth_token)
-          assert_receive {:render_with_user, _, _, _}, 4_000
+          assert_receive {:render_with_user, _, _, _, "user"}, 4_000
         end)
 
       task_user_notification =
@@ -232,7 +254,7 @@ defmodule Pleroma.NotificationTest do
           {:ok, _topic} =
             Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
 
-          assert_receive {:render_with_user, _, _, _}, 4_000
+          assert_receive {:render_with_user, _, _, _, "user:notification"}, 4_000
         end)
 
       activity = insert(:note_activity)
@@ -427,13 +449,12 @@ defmodule Pleroma.NotificationTest do
 
       {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user)
       assert FollowingRelationship.following?(user, followed_user)
-      assert [notification] = Notification.for_user(followed_user)
+      assert [_notification] = Notification.for_user(followed_user)
 
       CommonAPI.unfollow(user, followed_user)
       {:ok, _, _, _activity_dupe} = CommonAPI.follow(user, followed_user)
 
-      notification_id = notification.id
-      assert [%{id: ^notification_id}] = Notification.for_user(followed_user)
+      assert Enum.count(Notification.for_user(followed_user)) == 1
     end
 
     test "dismisses the notification on follow request rejection" do
@@ -839,6 +860,30 @@ defmodule Pleroma.NotificationTest do
       assert [other_user] == enabled_receivers
       assert [] == disabled_receivers
     end
+
+    test "it sends edited notifications to those who repeated a status" do
+      user = insert(:user)
+      repeated_user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, activity_one} =
+        CommonAPI.post(user, %{
+          status: "hey @#{other_user.nickname}!"
+        })
+
+      {:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user)
+
+      {:ok, edit_activity} =
+        CommonAPI.update(user, activity_one, %{
+          status: "hey @#{other_user.nickname}! mew mew"
+        })
+
+      {enabled_receivers, _disabled_receivers} =
+        Notification.get_notified_from_activity(edit_activity)
+
+      assert repeated_user in enabled_receivers
+      assert other_user not in enabled_receivers
+    end
   end
 
   describe "notification lifecycle" do
index bd0a6e497617bad3933654326b0017116d3edac7..c321032adb7f896fc794e16749ecae797959bdbc 100644 (file)
@@ -269,4 +269,271 @@ defmodule Pleroma.Object.FetcherTest do
       refute called(Pleroma.Signature.sign(:_, :_))
     end
   end
+
+  describe "refetching" do
+    setup do
+      object1 = %{
+        "id" => "https://mastodon.social/1",
+        "actor" => "https://mastodon.social/users/emelie",
+        "attributedTo" => "https://mastodon.social/users/emelie",
+        "type" => "Note",
+        "content" => "test 1",
+        "bcc" => [],
+        "bto" => [],
+        "cc" => [],
+        "to" => [],
+        "summary" => ""
+      }
+
+      object2 = %{
+        "id" => "https://mastodon.social/2",
+        "actor" => "https://mastodon.social/users/emelie",
+        "attributedTo" => "https://mastodon.social/users/emelie",
+        "type" => "Note",
+        "content" => "test 2",
+        "bcc" => [],
+        "bto" => [],
+        "cc" => [],
+        "to" => [],
+        "summary" => "",
+        "formerRepresentations" => %{
+          "type" => "OrderedCollection",
+          "orderedItems" => [
+            %{
+              "type" => "Note",
+              "content" => "orig 2",
+              "actor" => "https://mastodon.social/users/emelie",
+              "attributedTo" => "https://mastodon.social/users/emelie",
+              "bcc" => [],
+              "bto" => [],
+              "cc" => [],
+              "to" => [],
+              "summary" => ""
+            }
+          ],
+          "totalItems" => 1
+        }
+      }
+
+      mock(fn
+        %{
+          method: :get,
+          url: "https://mastodon.social/1"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body: Jason.encode!(object1)
+          }
+
+        %{
+          method: :get,
+          url: "https://mastodon.social/2"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body: Jason.encode!(object2)
+          }
+
+        %{
+          method: :get,
+          url: "https://mastodon.social/users/emelie/collections/featured"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body:
+              Jason.encode!(%{
+                "id" => "https://mastodon.social/users/emelie/collections/featured",
+                "type" => "OrderedCollection",
+                "actor" => "https://mastodon.social/users/emelie",
+                "attributedTo" => "https://mastodon.social/users/emelie",
+                "orderedItems" => [],
+                "totalItems" => 0
+              })
+          }
+
+        env ->
+          apply(HttpRequestMock, :request, [env])
+      end)
+
+      %{object1: object1, object2: object2}
+    end
+
+    test "it keeps formerRepresentations if remote does not have this attr", %{object1: object1} do
+      full_object1 =
+        object1
+        |> Map.merge(%{
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{
+                "type" => "Note",
+                "content" => "orig 2",
+                "actor" => "https://mastodon.social/users/emelie",
+                "attributedTo" => "https://mastodon.social/users/emelie",
+                "bcc" => [],
+                "bto" => [],
+                "cc" => [],
+                "to" => [],
+                "summary" => ""
+              }
+            ],
+            "totalItems" => 1
+          }
+        })
+
+      {:ok, o} = Object.create(full_object1)
+
+      assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} =
+               refetched.data
+    end
+
+    test "it uses formerRepresentations from remote if possible", %{object2: object2} do
+      {:ok, o} = Object.create(object2)
+
+      assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} =
+               refetched.data
+    end
+
+    test "it replaces formerRepresentations with the one from remote", %{object2: object2} do
+      full_object2 =
+        object2
+        |> Map.merge(%{
+          "content" => "mew mew #def",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{"type" => "Note", "content" => "mew mew 2"}
+            ],
+            "totalItems" => 1
+          }
+        })
+
+      {:ok, o} = Object.create(full_object2)
+
+      assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+      assert %{
+               "content" => "test 2",
+               "formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}
+             } = refetched.data
+    end
+
+    test "it adds to formerRepresentations if the remote does not have one and the object has changed",
+         %{object1: object1} do
+      full_object1 =
+        object1
+        |> Map.merge(%{
+          "content" => "mew mew #def",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{"type" => "Note", "content" => "mew mew 1"}
+            ],
+            "totalItems" => 1
+          }
+        })
+
+      {:ok, o} = Object.create(full_object1)
+
+      assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+      assert %{
+               "content" => "test 1",
+               "formerRepresentations" => %{
+                 "orderedItems" => [
+                   %{"content" => "mew mew #def"},
+                   %{"content" => "mew mew 1"}
+                 ],
+                 "totalItems" => 2
+               }
+             } = refetched.data
+    end
+  end
+
+  describe "fetch with history" do
+    setup do
+      object2 = %{
+        "id" => "https://mastodon.social/2",
+        "actor" => "https://mastodon.social/users/emelie",
+        "attributedTo" => "https://mastodon.social/users/emelie",
+        "type" => "Note",
+        "content" => "test 2",
+        "bcc" => [],
+        "bto" => [],
+        "cc" => ["https://mastodon.social/users/emelie/followers"],
+        "to" => [],
+        "summary" => "",
+        "formerRepresentations" => %{
+          "type" => "OrderedCollection",
+          "orderedItems" => [
+            %{
+              "type" => "Note",
+              "content" => "orig 2",
+              "actor" => "https://mastodon.social/users/emelie",
+              "attributedTo" => "https://mastodon.social/users/emelie",
+              "bcc" => [],
+              "bto" => [],
+              "cc" => ["https://mastodon.social/users/emelie/followers"],
+              "to" => [],
+              "summary" => ""
+            }
+          ],
+          "totalItems" => 1
+        }
+      }
+
+      mock(fn
+        %{
+          method: :get,
+          url: "https://mastodon.social/2"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body: Jason.encode!(object2)
+          }
+
+        %{
+          method: :get,
+          url: "https://mastodon.social/users/emelie/collections/featured"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body:
+              Jason.encode!(%{
+                "id" => "https://mastodon.social/users/emelie/collections/featured",
+                "type" => "OrderedCollection",
+                "actor" => "https://mastodon.social/users/emelie",
+                "attributedTo" => "https://mastodon.social/users/emelie",
+                "orderedItems" => [],
+                "totalItems" => 0
+              })
+          }
+
+        env ->
+          apply(HttpRequestMock, :request, [env])
+      end)
+
+      %{object2: object2}
+    end
+
+    test "it gets history", %{object2: object2} do
+      {:ok, object} = Fetcher.fetch_object_from_id(object2["id"])
+
+      assert %{
+               "formerRepresentations" => %{
+                 "type" => "OrderedCollection",
+                 "orderedItems" => [%{}]
+               }
+             } = object.data
+    end
+  end
 end
diff --git a/test/pleroma/object/updater_test.exs b/test/pleroma/object/updater_test.exs
new file mode 100644 (file)
index 0000000..7e9b448
--- /dev/null
@@ -0,0 +1,76 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Object.UpdaterTest do
+  use Pleroma.DataCase
+  use Oban.Testing, repo: Pleroma.Repo
+
+  import Pleroma.Factory
+
+  alias Pleroma.Object.Updater
+
+  describe "make_update_object_data/3" do
+    setup do
+      note = insert(:note)
+      %{original_data: note.data}
+    end
+
+    test "it makes an updated field", %{original_data: original_data} do
+      new_data = Map.put(original_data, "content", "new content")
+
+      date = Pleroma.Web.ActivityPub.Utils.make_date()
+      update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+      assert %{"updated" => ^date} = update_object_data
+    end
+
+    test "it creates formerRepresentations", %{original_data: original_data} do
+      new_data = Map.put(original_data, "content", "new content")
+
+      date = Pleroma.Web.ActivityPub.Utils.make_date()
+      update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+
+      history_item = original_data |> Map.drop(["id", "formerRepresentations"])
+
+      assert %{
+               "formerRepresentations" => %{
+                 "totalItems" => 1,
+                 "orderedItems" => [^history_item]
+               }
+             } = update_object_data
+    end
+  end
+
+  describe "make_new_object_data_from_update_object/2" do
+    test "it reuses formerRepresentations if it exists" do
+      %{data: original_data} = insert(:note)
+
+      new_data =
+        original_data
+        |> Map.put("content", "edited")
+
+      date = Pleroma.Web.ActivityPub.Utils.make_date()
+      update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+
+      history = update_object_data["formerRepresentations"]["orderedItems"]
+
+      update_object_data =
+        update_object_data
+        |> put_in(
+          ["formerRepresentations", "orderedItems"],
+          history ++ [Map.put(original_data, "summary", "additional summary")]
+        )
+        |> put_in(["formerRepresentations", "totalItems"], length(history) + 1)
+
+      %{
+        updated_data: updated_data,
+        updated: updated,
+        used_history_in_new_object?: used_history_in_new_object?
+      } = Updater.make_new_object_data_from_update_object(original_data, update_object_data)
+
+      assert updated
+      assert used_history_in_new_object?
+      assert updated_data["formerRepresentations"] == update_object_data["formerRepresentations"]
+    end
+  end
+end
diff --git a/test/pleroma/translators/deepl_test.exs b/test/pleroma/translators/deepl_test.exs
new file mode 100644 (file)
index 0000000..d85bef9
--- /dev/null
@@ -0,0 +1,146 @@
+defmodule Pleroma.Akkoma.Translators.DeepLTest do
+  use Pleroma.DataCase, async: true
+
+  alias Pleroma.Akkoma.Translators.DeepL
+
+  describe "translating with deepl" do
+    setup do
+      clear_config([:deepl, :api_key], "deepl_api_key")
+    end
+
+    test "should list supported languages" do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock(fn
+        %{method: :get, url: "https://api-free.deepl.com/v2/languages?type=target"} = env ->
+          auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+          assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!([
+                %{
+                  "language" => "BG",
+                  "name" => "Bulgarian",
+                  "supports_formality" => false
+                },
+                %{
+                  "language" => "CS",
+                  "name" => "Czech",
+                  "supports_formality" => false
+                }
+              ])
+          }
+
+        %{method: :get, url: "https://api-free.deepl.com/v2/languages?type=source"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!([
+                %{
+                  "language" => "JA",
+                  "name" => "Japanese",
+                  "supports_formality" => false
+                }
+              ])
+          }
+      end)
+
+      assert {:ok, [%{code: "JA", name: "Japanese"}],
+              [%{code: "BG", name: "Bulgarian"}, %{code: "CS", name: "Czech"}]} =
+               DeepL.languages()
+    end
+
+    test "should work with the free tier" do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "https://api-free.deepl.com/v2/translate"} = env ->
+          auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+          assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "I will crush you",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+    end
+
+    test "should work with the pro tier" do
+      clear_config([:deepl, :tier], :pro)
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "https://api.deepl.com/v2/translate"} = env ->
+          auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+          assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "I will crush you",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+    end
+
+    test "should assign source language if set" do
+      clear_config([:deepl, :tier], :pro)
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "https://api.deepl.com/v2/translate"} = env ->
+          auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+          assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+          assert String.contains?(env.body, "source_lang=ja")
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "I will crush you",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", "ja", "en")
+    end
+
+    test "should gracefully fail if the API errors" do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+          %Tesla.Env{
+            status: 403,
+            body: ""
+          }
+      end)
+
+      assert {:error, "DeepL request failed (code 403)"} =
+               DeepL.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+    end
+  end
+end
diff --git a/test/pleroma/translators/libre_translate_test.exs b/test/pleroma/translators/libre_translate_test.exs
new file mode 100644 (file)
index 0000000..3c81c3d
--- /dev/null
@@ -0,0 +1,137 @@
+defmodule Pleroma.Akkoma.Translators.LibreTranslateTest do
+  use Pleroma.DataCase, async: true
+
+  alias Pleroma.Akkoma.Translators.LibreTranslate
+
+  describe "translating with libre translate" do
+    setup do
+      clear_config([:libre_translate, :url], "http://libre.translate/translate")
+    end
+
+    test "should list supported languages" do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock(fn
+        %{method: :get, url: "http://libre.translate/languages"} = _ ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!([
+                %{
+                  "code" => "en",
+                  "name" => "English"
+                },
+                %{
+                  "code" => "ar",
+                  "name" => "Arabic"
+                }
+              ])
+          }
+      end)
+
+      assert {:ok, [%{code: "en", name: "English"}, %{code: "ar", name: "Arabic"}],
+              [%{code: "en", name: "English"}, %{code: "ar", name: "Arabic"}]} =
+               LibreTranslate.languages()
+    end
+
+    test "should work without an API key" do
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} = env ->
+          assert {:ok, %{"api_key" => nil, "source" => "auto"}} = Jason.decode(env.body)
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                detectedLanguage: %{
+                  confidence: 83,
+                  language: "ja"
+                },
+                translatedText: "I will crush you"
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} =
+               LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+    end
+
+    test "should work with an API key" do
+      clear_config([:libre_translate, :api_key], "libre_translate_api_key")
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} = env ->
+          assert {:ok, %{"api_key" => "libre_translate_api_key"}} = Jason.decode(env.body)
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                detectedLanguage: %{
+                  confidence: 83,
+                  language: "ja"
+                },
+                translatedText: "I will crush you"
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} =
+               LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+    end
+
+    test "should gracefully handle API key errors" do
+      clear_config([:libre_translate, :api_key], "")
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} ->
+          %Tesla.Env{
+            status: 403,
+            body:
+              Jason.encode!(%{
+                error: "Please contact the server operator to obtain an API key"
+              })
+          }
+      end)
+
+      assert {:error, "libre_translate: request failed (code 403)"} =
+               LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+    end
+
+    test "should set a source language if requested" do
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} = env ->
+          assert {:ok, %{"api_key" => nil, "source" => "ja"}} = Jason.decode(env.body)
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translatedText: "I will crush you"
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} =
+               LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "ja", "en")
+    end
+
+    test "should gracefully handle an unsupported language" do
+      clear_config([:libre_translate, :api_key], "")
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} ->
+          %Tesla.Env{
+            status: 400,
+            body:
+              Jason.encode!(%{
+                error: "zoop is not supported"
+              })
+          }
+      end)
+
+      assert {:error, "libre_translate: request failed (code 400)"} =
+               LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "zoop")
+    end
+  end
+end
index f1ab82a57441b00ad37ec8e8ffcb23504ece8719..8f242630ffbaecfb2d12afaea429cb7048b0a27f 100644 (file)
@@ -49,20 +49,22 @@ defmodule Pleroma.UploadTest do
     test "it returns file" do
       File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
 
-      assert Upload.store(@upload_file) ==
-               {:ok,
-                %{
-                  "name" => "image.jpg",
-                  "type" => "Document",
-                  "mediaType" => "image/jpeg",
-                  "url" => [
-                    %{
-                      "href" => "http://localhost:4001/media/post-process-file.jpg",
-                      "mediaType" => "image/jpeg",
-                      "type" => "Link"
-                    }
-                  ]
-                }}
+      assert {:ok, result} = Upload.store(@upload_file)
+
+      assert result ==
+               %{
+                 "id" => result["id"],
+                 "name" => "image.jpg",
+                 "type" => "Document",
+                 "mediaType" => "image/jpeg",
+                 "url" => [
+                   %{
+                     "href" => "http://localhost:4001/media/post-process-file.jpg",
+                     "mediaType" => "image/jpeg",
+                     "type" => "Link"
+                   }
+                 ]
+               }
 
       Task.await(Agent.get(TestUploaderSuccess, fn task_pid -> task_pid end))
     end
index da5c87bd840fe101f01276c3d0a5747c25169a09..e209bb46bbfe2f1749d0ca6176eb89a605c0b2e6 100644 (file)
@@ -782,6 +782,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
         |> String.replace("{{status_id}}", status_id)
 
       status_url = "https://example.com/users/lain/statuses/#{status_id}"
+      replies_url = status_url <> "/replies?only_other_accounts=true&page=true"
 
       user =
         File.read!("test/fixtures/users_mock/user.json")
@@ -820,6 +821,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
               |> String.replace("{{nickname}}", "lain"),
             headers: [{"content-type", "application/activity+json"}]
           }
+
+        %{
+          method: :get,
+          url: ^replies_url
+        } ->
+          %Tesla.Env{
+            status: 404,
+            body: "",
+            headers: [{"content-type", "application/activity+json"}]
+          }
       end)
 
       data = %{
index 50eff94317bcf8424b861c6dff378438db6fec0f..ec562ac7b2e8abcd9caf57c89eb5efd1a0e2334d 100644 (file)
@@ -1373,6 +1373,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
       assert embedded_object["id"] == follow_activity.data["id"]
     end
 
+    test "it removes the follow activity if it was local" do
+      follower = insert(:user, local: true)
+      followed = insert(:user)
+
+      {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed)
+      {:ok, activity} = ActivityPub.unfollow(follower, followed, nil, true)
+
+      assert activity.data["type"] == "Undo"
+      assert activity.data["actor"] == follower.ap_id
+
+      follow_activity = Activity.get_by_id(follow_activity.id)
+      assert is_nil(follow_activity)
+      assert is_nil(Utils.fetch_latest_follow(follower, followed))
+
+      # We need to keep our own undo
+      undo_activity = Activity.get_by_ap_id(activity.data["id"])
+      refute is_nil(undo_activity)
+    end
+
     test "it removes the follow activity if it was remote" do
       follower = insert(:user, local: false)
       followed = insert(:user)
@@ -1383,9 +1402,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
       assert activity.data["type"] == "Undo"
       assert activity.data["actor"] == follower.ap_id
 
-      activity = Activity.get_by_id(follow_activity.id)
-      assert is_nil(activity)
+      follow_activity = Activity.get_by_id(follow_activity.id)
+      assert is_nil(follow_activity)
       assert is_nil(Utils.fetch_latest_follow(follower, followed))
+
+      undo_activity = Activity.get_by_ap_id(activity.data["id"])
+      assert is_nil(undo_activity)
     end
   end
 
index 640caa2b64c71899fd9cb099abf43c7d6548fd5f..9269733b738a8d2aa80aaba823708af253e48b6c 100644 (file)
@@ -48,4 +48,61 @@ defmodule Pleroma.Web.ActivityPub.BuilderTest do
       assert {:ok, ^expected, []} = Builder.note(draft)
     end
   end
+
+  describe "emoji_react/1" do
+    test "unicode emoji" do
+      user = insert(:user)
+      note = insert(:note)
+
+      assert {:ok, %{"content" => "👍", "type" => "EmojiReact"}, []} =
+               Builder.emoji_react(user, note, "👍")
+    end
+
+    test "custom emoji" do
+      user = insert(:user)
+      note = insert(:note)
+
+      assert {:ok,
+              %{
+                "content" => ":dinosaur:",
+                "type" => "EmojiReact",
+                "tag" => [
+                  %{
+                    "name" => ":dinosaur:",
+                    "id" => "http://localhost:4001/emoji/dino walking.gif",
+                    "icon" => %{
+                      "type" => "Image",
+                      "url" => "http://localhost:4001/emoji/dino walking.gif"
+                    }
+                  }
+                ]
+              }, []} = Builder.emoji_react(user, note, ":dinosaur:")
+    end
+
+    test "remote custom emoji" do
+      user = insert(:user)
+      other_user = insert(:user, local: false)
+
+      note =
+        insert(:note,
+          data: %{"reactions" => [["wow", [other_user.ap_id], "https://remote/emoji/wow"]]}
+        )
+
+      assert {:ok,
+              %{
+                "content" => ":wow:",
+                "type" => "EmojiReact",
+                "tag" => [
+                  %{
+                    "name" => ":wow:",
+                    "id" => "https://remote/emoji/wow",
+                    "icon" => %{
+                      "type" => "Image",
+                      "url" => "https://remote/emoji/wow"
+                    }
+                  }
+                ]
+              }, []} = Builder.emoji_react(user, note, ":wow@remote:")
+    end
+  end
 end
index 5b990451c238ab673cfab5c9e6b1abb4dfac3c46..c3ee03a054f7974e290077c81e9fe27b7476aff1 100644 (file)
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
   import Pleroma.Factory
   import ExUnit.CaptureLog
 
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy
 
   @linkless_message %{
@@ -49,15 +50,39 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
 
       assert user.note_count == 0
 
+      message = %{
+        "type" => "Create",
+        "actor" => user.ap_id,
+        "object" => %{
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{
+                "content" => "<a href='https://example.com'>hi world!</a>"
+              }
+            ]
+          },
+          "content" => "mew"
+        }
+      }
+
+      {:reject, _} = MRF.filter_one(AntiLinkSpamPolicy, message)
+    end
+
+    test "it allows posts with links for local users" do
+      user = insert(:user)
+
+      assert user.note_count == 0
+
       message =
         @linkful_message
         |> Map.put("actor", user.ap_id)
 
-      {:reject, _} = AntiLinkSpamPolicy.filter(message)
+      {:ok, _message} = AntiLinkSpamPolicy.filter(message)
     end
 
-    test "it allows posts with links for local users" do
-      user = insert(:user)
+    test "it disallows posts with links in history" do
+      user = insert(:user, local: false)
 
       assert user.note_count == 0
 
@@ -65,7 +90,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
         @linkful_message
         |> Map.put("actor", user.ap_id)
 
-      {:ok, _message} = AntiLinkSpamPolicy.filter(message)
+      {:reject, _} = AntiLinkSpamPolicy.filter(message)
     end
   end
 
index 89439b65fe8a88e102f0aa670f0814d16269b34e..e174a83f7e7d441bd0b0b4dd1fe2278af74a747c 100644 (file)
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do
 
   alias Pleroma.Activity
   alias Pleroma.Object
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.EnsureRePrepended
 
   describe "rewrites summary" do
@@ -35,10 +36,58 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do
       assert {:ok, res} = EnsureRePrepended.filter(message)
       assert res["object"]["summary"] == "re: object-summary"
     end
+
+    test "it adds `re:` to history" do
+      message = %{
+        "type" => "Create",
+        "object" => %{
+          "summary" => "object-summary",
+          "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}},
+          "formerRepresentations" => %{
+            "orderedItems" => [
+              %{
+                "summary" => "object-summary",
+                "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}
+              }
+            ]
+          }
+        }
+      }
+
+      assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message)
+      assert res["object"]["summary"] == "re: object-summary"
+
+      assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] ==
+               "re: object-summary"
+    end
+
+    test "it accepts Updates" do
+      message = %{
+        "type" => "Update",
+        "object" => %{
+          "summary" => "object-summary",
+          "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}},
+          "formerRepresentations" => %{
+            "orderedItems" => [
+              %{
+                "summary" => "object-summary",
+                "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}
+              }
+            ]
+          }
+        }
+      }
+
+      assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message)
+      assert res["object"]["summary"] == "re: object-summary"
+
+      assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] ==
+               "re: object-summary"
+    end
   end
 
   describe "skip filter" do
-    test "it skip if type isn't 'Create'" do
+    test "it skip if type isn't 'Create' or 'Update'" do
       message = %{
         "type" => "Annotation",
         "object" => %{"summary" => "object-summary"}
index 13415bb79ef168d13b4e1f3009bb91deb3db07ed..b88090869fe2fa23644ca6a1d8c9e264e3a14efc 100644 (file)
@@ -20,6 +20,76 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicyTest do
     assert modified["object"]["sensitive"]
   end
 
+  test "it is history-aware" do
+    activity = %{
+      "type" => "Create",
+      "object" => %{
+        "content" => "hey",
+        "tag" => []
+      }
+    }
+
+    activity_data =
+      activity
+      |> put_in(
+        ["object", "formerRepresentations"],
+        %{
+          "type" => "OrderedCollection",
+          "orderedItems" => [
+            Map.put(
+              activity["object"],
+              "tag",
+              [%{"type" => "Hashtag", "name" => "#nsfw"}]
+            )
+          ]
+        }
+      )
+
+    {:ok, modified} =
+      Pleroma.Web.ActivityPub.MRF.filter_one(
+        Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
+        activity_data
+      )
+
+    refute modified["object"]["sensitive"]
+    assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"]
+  end
+
+  test "it works with Update" do
+    activity = %{
+      "type" => "Update",
+      "object" => %{
+        "content" => "hey",
+        "tag" => []
+      }
+    }
+
+    activity_data =
+      activity
+      |> put_in(
+        ["object", "formerRepresentations"],
+        %{
+          "type" => "OrderedCollection",
+          "orderedItems" => [
+            Map.put(
+              activity["object"],
+              "tag",
+              [%{"type" => "Hashtag", "name" => "#nsfw"}]
+            )
+          ]
+        }
+      )
+
+    {:ok, modified} =
+      Pleroma.Web.ActivityPub.MRF.filter_one(
+        Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
+        activity_data
+      )
+
+    refute modified["object"]["sensitive"]
+    assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"]
+  end
+
   test "it doesn't sets the sensitive property with irrelevant hashtags" do
     user = insert(:user)
 
index 8af4c5efad8929bf65fdb26e815465d3a64f50fe..9bc8c835503bbc9e2ec9f4ae25f1b6fc3c5911ca 100644 (file)
@@ -79,6 +79,54 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do
                    KeywordPolicy.filter(message)
                end)
     end
+
+    test "rejects if string matches in history" do
+      clear_config([:mrf_keyword, :reject], ["pun"])
+
+      message = %{
+        "type" => "Create",
+        "object" => %{
+          "content" => "just a daily reminder that compLAINer is a good",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{
+                "content" => "just a daily reminder that compLAINer is a good pun",
+                "summary" => ""
+              }
+            ]
+          }
+        }
+      }
+
+      assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} =
+               KeywordPolicy.filter(message)
+    end
+
+    test "rejects Updates" do
+      clear_config([:mrf_keyword, :reject], ["pun"])
+
+      message = %{
+        "type" => "Update",
+        "object" => %{
+          "content" => "just a daily reminder that compLAINer is a good",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{
+                "content" => "just a daily reminder that compLAINer is a good pun",
+                "summary" => ""
+              }
+            ]
+          }
+        }
+      }
+
+      assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} =
+               KeywordPolicy.filter(message)
+    end
   end
 
   describe "delisting from ftl based on keywords" do
@@ -157,6 +205,31 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do
                    not (["https://www.w3.org/ns/activitystreams#Public"] == result["to"])
                end)
     end
+
+    test "delists if string matches in history" do
+      clear_config([:mrf_keyword, :federated_timeline_removal], ["pun"])
+
+      message = %{
+        "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+        "type" => "Create",
+        "object" => %{
+          "content" => "just a daily reminder that compLAINer is a good",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "orderedItems" => [
+              %{
+                "content" => "just a daily reminder that compLAINer is a good pun",
+                "summary" => ""
+              }
+            ]
+          }
+        }
+      }
+
+      {:ok, result} = KeywordPolicy.filter(message)
+      assert ["https://www.w3.org/ns/activitystreams#Public"] == result["cc"]
+      refute ["https://www.w3.org/ns/activitystreams#Public"] == result["to"]
+    end
   end
 
   describe "replacing keywords" do
@@ -221,5 +294,63 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do
                  result == "ZFS is free software"
                end)
     end
+
+    test "replaces keyword if string matches in history" do
+      clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}])
+
+      message = %{
+        "type" => "Create",
+        "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+        "object" => %{
+          "content" => "ZFS is opensource",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{"content" => "ZFS is opensource mew mew", "summary" => ""}
+            ]
+          }
+        }
+      }
+
+      {:ok,
+       %{
+         "object" => %{
+           "content" => "ZFS is free software",
+           "formerRepresentations" => %{
+             "orderedItems" => [%{"content" => "ZFS is free software mew mew"}]
+           }
+         }
+       }} = KeywordPolicy.filter(message)
+    end
+
+    test "replaces keyword in Updates" do
+      clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}])
+
+      message = %{
+        "type" => "Update",
+        "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+        "object" => %{
+          "content" => "ZFS is opensource",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{"content" => "ZFS is opensource mew mew", "summary" => ""}
+            ]
+          }
+        }
+      }
+
+      {:ok,
+       %{
+         "object" => %{
+           "content" => "ZFS is free software",
+           "formerRepresentations" => %{
+             "orderedItems" => [%{"content" => "ZFS is free software mew mew"}]
+           }
+         }
+       }} = KeywordPolicy.filter(message)
+    end
   end
 end
index 96e715d0d859275c163339ea36f459fa79582bb0..3268e23211de3c8f7295babb65a2593ba3cb1f5e 100644 (file)
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
   use Pleroma.Tests.Helpers
 
   alias Pleroma.HTTP
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy
 
   import Mock
@@ -22,6 +23,25 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
     }
   }
 
+  @message_with_history %{
+    "type" => "Create",
+    "object" => %{
+      "type" => "Note",
+      "content" => "content",
+      "formerRepresentations" => %{
+        "orderedItems" => [
+          %{
+            "type" => "Note",
+            "content" => "content",
+            "attachment" => [
+              %{"url" => [%{"href" => "http://example.com/image.jpg"}]}
+            ]
+          }
+        ]
+      }
+    }
+  }
+
   setup do: clear_config([:media_proxy, :enabled], true)
 
   test "it prefetches media proxy URIs" do
@@ -50,4 +70,28 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
       refute called(HTTP.get(:_, :_, :_))
     end
   end
+
+  test "history-aware" do
+    Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
+      {:ok, %Tesla.Env{status: 200, body: ""}}
+    end)
+
+    with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
+      MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history)
+
+      assert called(HTTP.get(:_, :_, :_))
+    end
+  end
+
+  test "works with Updates" do
+    Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
+      {:ok, %Tesla.Env{status: 200, body: ""}}
+    end)
+
+    with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
+      MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update"))
+
+      assert called(HTTP.get(:_, :_, :_))
+    end
+  end
 end
index 2c6fcbc416e6b3c49a6f0c3f884c0b7cbc37c92d..d9e05d3131f3f8dede09d27f4cd1c86ed13f69c0 100644 (file)
@@ -153,4 +153,27 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicyTest do
 
     assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
   end
+
+  test "works with Update" do
+    message = %{
+      "actor" => "http://localhost:4001/users/testuser",
+      "cc" => ["http://localhost:4001/users/testuser/followers"],
+      "object" => %{
+        "actor" => "http://localhost:4001/users/testuser",
+        "attachment" => [],
+        "cc" => ["http://localhost:4001/users/testuser/followers"],
+        "source" => "",
+        "to" => [
+          "https://www.w3.org/ns/activitystreams#Public"
+        ],
+        "type" => "Note"
+      },
+      "to" => [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "type" => "Update"
+    }
+
+    assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
+  end
 end
index 81a6e0f507a33046ad127079459991f26b914f43..59456d79064ba99ddfb6fcf83314a3674b937241 100644 (file)
@@ -4,6 +4,7 @@
 
 defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do
   use Pleroma.DataCase, async: true
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy
 
   test "it clears content object" do
@@ -20,6 +21,46 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do
     assert res["object"]["content"] == ""
   end
 
+  test "history-aware" do
+    message = %{
+      "type" => "Create",
+      "object" => %{
+        "content" => ".",
+        "attachment" => "image",
+        "formerRepresentations" => %{
+          "orderedItems" => [%{"content" => ".", "attachment" => "image"}]
+        }
+      }
+    }
+
+    assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message)
+
+    assert %{
+             "content" => "",
+             "formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]}
+           } = res["object"]
+  end
+
+  test "works with Updates" do
+    message = %{
+      "type" => "Update",
+      "object" => %{
+        "content" => ".",
+        "attachment" => "image",
+        "formerRepresentations" => %{
+          "orderedItems" => [%{"content" => ".", "attachment" => "image"}]
+        }
+      }
+    }
+
+    assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message)
+
+    assert %{
+             "content" => "",
+             "formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]}
+           } = res["object"]
+  end
+
   @messages [
     %{
       "type" => "Create",
index edc330b6cb1c297ee79a1f458ad659002f5257df..52a23fdca4723d50a674edbf132046cf2d317014 100644 (file)
@@ -4,6 +4,7 @@
 
 defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
   use Pleroma.DataCase, async: true
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.NormalizeMarkup
 
   @html_sample """
@@ -16,24 +17,58 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
   <script>alert('hacked')</script>
   """
 
-  test "it filter html tags" do
-    expected = """
-    <b>this is in bold</b>
-    <p>this is a paragraph</p>
-    this is a linebreak<br/>
-    this is a link with allowed &quot;rel&quot; attribute: <a href="http://example.com/" rel="tag">example.com</a>
-    this is a link with not allowed &quot;rel&quot; attribute: <a href="http://example.com/">example.com</a>
-    this is an image: <img src="http://example.com/image.jpg"/><br/>
-    alert(&#39;hacked&#39;)
-    """
+  @expected """
+  <b>this is in bold</b>
+  <p>this is a paragraph</p>
+  this is a linebreak<br/>
+  this is a link with allowed &quot;rel&quot; attribute: <a href="http://example.com/" rel="tag">example.com</a>
+  this is a link with not allowed &quot;rel&quot; attribute: <a href="http://example.com/">example.com</a>
+  this is an image: <img src="http://example.com/image.jpg"/><br/>
+  alert(&#39;hacked&#39;)
+  """
 
+  test "it filter html tags" do
     message = %{"type" => "Create", "object" => %{"content" => @html_sample}}
 
     assert {:ok, res} = NormalizeMarkup.filter(message)
-    assert res["object"]["content"] == expected
+    assert res["object"]["content"] == @expected
+  end
+
+  test "history-aware" do
+    message = %{
+      "type" => "Create",
+      "object" => %{
+        "content" => @html_sample,
+        "formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]}
+      }
+    }
+
+    assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message)
+
+    assert %{
+             "content" => @expected,
+             "formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]}
+           } = res["object"]
+  end
+
+  test "works with Updates" do
+    message = %{
+      "type" => "Update",
+      "object" => %{
+        "content" => @html_sample,
+        "formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]}
+      }
+    }
+
+    assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message)
+
+    assert %{
+             "content" => @expected,
+             "formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]}
+           } = res["object"]
   end
 
-  test "it skips filter if type isn't `Create`" do
+  test "it skips filter if type isn't `Create` or `Update`" do
     message = %{"type" => "Note", "object" => %{}}
 
     assert {:ok, res} = NormalizeMarkup.filter(message)
index 0a0f51bdbb8c91bf27dc903f216579414e900887..0569bfed309e8ed2cacbcc7d8950e91f2f528062 100644 (file)
@@ -216,6 +216,43 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do
     end
   end
 
+  describe "describe/1" do
+    test "returns a description of the policy" do
+      clear_config([:mrf_simple, :reject], [
+        {"remote.instance", "did not give my catboy a burg"}
+      ])
+
+      assert {:ok, %{mrf_simple: %{reject: ["remote.instance"]}}} = SimplePolicy.describe()
+    end
+
+    test "excludes domains listed in :transparency_exclusions" do
+      clear_config([:mrf, :transparency_exclusions], [{"remote.instance", ":("}])
+
+      clear_config([:mrf_simple, :reject], [
+        {"remote.instance", "did not give my catboy a burg"}
+      ])
+
+      {:ok, description} = SimplePolicy.describe()
+      assert %{mrf_simple: %{reject: []}} = description
+      assert description[:mrf_simple_info][:reject] == nil
+    end
+
+    test "obfuscates domains listed in :transparency_obfuscate_domains" do
+      clear_config([:mrf, :transparency_obfuscate_domains], ["remote.instance", "a.b"])
+
+      clear_config([:mrf_simple, :reject], [
+        {"remote.instance", "did not give my catboy a burg"},
+        {"a.b", "spam-poked me on facebook in 2006"}
+      ])
+
+      assert {:ok,
+              %{
+                mrf_simple: %{reject: ["rem***.*****nce", "a.b"]},
+                mrf_simple_info: %{reject: %{"rem***.*****nce" => %{}}}
+              }} = SimplePolicy.describe()
+    end
+  end
+
   defp build_ftl_actor_and_message do
     actor = insert(:user)
 
index 6ab27bc8676f91d9c4bc234fbae7c1240e99776f..ed3233758b54160a8edc918ec2952de356523641 100644 (file)
@@ -77,7 +77,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
       clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.NoOpPolicy])
 
       expected = %{
-        mrf_policies: ["NoOpPolicy", "HashtagPolicy"],
+        mrf_policies: ["NoOpPolicy", "HashtagPolicy", "InlineQuotePolicy"],
         mrf_hashtag: %{
           federated_timeline_removal: [],
           reject: [],
@@ -93,7 +93,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do
       clear_config([:mrf, :policies], [MRFModuleMock])
 
       expected = %{
-        mrf_policies: ["MRFModuleMock", "HashtagPolicy"],
+        mrf_policies: ["MRFModuleMock", "HashtagPolicy", "InlineQuotePolicy"],
         mrf_module_mock: "some config data",
         mrf_hashtag: %{
           federated_timeline_removal: [],
index 09cd1a964f55b694ff6dd4c784213284bab68a1d..5b95ebc51f61877867ad1b76bd3327a4e85b9897 100644 (file)
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest do
   use Pleroma.DataCase, async: true
 
+  alias Pleroma.Web.ActivityPub.ObjectValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator
   alias Pleroma.Web.ActivityPub.Utils
 
@@ -38,6 +39,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest
       %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
     end
 
+    test "a note from factory validates" do
+      note = insert(:note)
+      %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note.data)
+    end
+
     test "a note with a remote replies collection should validate", _ do
       insert(:user, %{ap_id: "https://bookwyrm.com/user/TestUser"})
       collection = File.read!("test/fixtures/bookwyrm-replies-collection.json")
@@ -98,8 +104,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest
         changes: %{
           content: content,
           source: %{
-            "content" =>
-              "@akkoma_user @remote_user @full_tag_remote_user@misskey.local.live @oops_not_a_mention linkifylink #dancedance $[jelly mfm goes here] \n\n## aaa",
             "mediaType" => "text/x.misskeymarkdown"
           }
         }
@@ -115,7 +119,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest
                "<span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{full_tag_remote_user.id}\" href=\"#{full_tag_remote_user.ap_id}\" rel=\"ugc\">@<span>full_tag_remote_user</span></a></span>"
 
       assert content =~ "@oops_not_a_mention"
-      assert content =~ "$[jelly mfm goes here] <br><br>## aaa"
+
+      assert content =~
+               "<span class=\"mfm _mfm_jelly_\" style=\"display: inline-block; animation: 1s linear 0s infinite normal both running mfm-rubberBand;\">mfm goes here</span> </p>aaa"
     end
 
     test "a misskey MFM status with a _misskey_content field should work and be linked", _ do
@@ -129,22 +135,77 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest
         |> File.read!()
         |> Jason.decode!()
 
-      expected_content =
-        "<span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{local_user.id}\" href=\"#{local_user.ap_id}\" rel=\"ugc\">@<span>akkoma_user</span></a></span> linkifylink <a class=\"hashtag\" data-tag=\"dancedance\" href=\"http://localhost:4001/tag/dancedance\">#dancedance</a> $[jelly mfm goes here] <br><br>## aaa"
-
       changes = ArticleNotePageValidator.cast_and_validate(note)
 
       %{
         valid?: true,
         changes: %{
+          content: content,
           source: %{
-            "content" => "@akkoma_user linkifylink #dancedance $[jelly mfm goes here] \n\n## aaa",
-            "mediaType" => "text/x.misskeymarkdown"
+            "mediaType" => "text/x.misskeymarkdown",
+            "content" => "@akkoma_user linkifylink #dancedance $[jelly mfm goes here] \n\n## aaa"
           }
         }
       } = changes
 
-      assert changes.changes[:content] == expected_content
+      assert content =~
+               "<span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{local_user.id}\" href=\"#{local_user.ap_id}\" rel=\"ugc\">@<span>akkoma_user</span></a></span>"
+    end
+  end
+
+  test "a Note without replies/first/items validates" do
+    insert(:user, ap_id: "https://mastodon.social/users/emelie")
+
+    note =
+      "test/fixtures/tesla_mock/status.emelie.json"
+      |> File.read!()
+      |> Jason.decode!()
+      |> pop_in(["replies", "first", "items"])
+      |> elem(1)
+
+    %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
+  end
+
+  describe "Note with history" do
+    setup do
+      user = insert(:user)
+      {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"})
+      {:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"})
+
+      {:ok, %{"object" => external_rep}} =
+        Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data)
+
+      %{external_rep: external_rep}
+    end
+
+    test "edited note", %{external_rep: external_rep} do
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"tag" => [_]}]}} = external_rep
+
+      {:ok, validate_res, []} = ObjectValidator.validate(external_rep, [])
+
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} =
+               validate_res
+    end
+
+    test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do
+      external_rep = Map.put(external_rep, "formerRepresentations", %{})
+
+      assert {:error, _} = ObjectValidator.validate(external_rep, [])
+    end
+
+    test "edited note, badly-formed history item", %{external_rep: external_rep} do
+      history_item =
+        Enum.at(external_rep["formerRepresentations"]["orderedItems"], 0)
+        |> Map.put("type", "Foo")
+
+      external_rep =
+        put_in(
+          external_rep,
+          ["formerRepresentations", "orderedItems"],
+          [history_item]
+        )
+
+      assert {:error, _} = ObjectValidator.validate(external_rep, [])
     end
   end
 end
index 15e4a82cd0a7d0c9ac061803f978e21b26d3bba4..a74ee2416710ba12191c03f8419bb05a1eb38faa 100644 (file)
@@ -32,7 +32,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do
     test "returns an error if the object can't be updated by the actor", %{
       valid_update: valid_update
     } do
-      other_user = insert(:user)
+      other_user = insert(:user, local: false)
 
       update =
         valid_update
@@ -40,5 +40,129 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do
 
       assert {:error, _cng} = ObjectValidator.validate(update, [])
     end
+
+    test "validates as long as the object is same-origin with the actor", %{
+      valid_update: valid_update
+    } do
+      other_user = insert(:user)
+
+      update =
+        valid_update
+        |> Map.put("actor", other_user.ap_id)
+
+      assert {:ok, _update, []} = ObjectValidator.validate(update, [])
+    end
+
+    test "validates if the object is not of an Actor type" do
+      note = insert(:note)
+      updated_note = note.data |> Map.put("content", "edited content")
+      other_user = insert(:user)
+
+      {:ok, update, _} = Builder.update(other_user, updated_note)
+
+      assert {:ok, _update, _} = ObjectValidator.validate(update, [])
+    end
+  end
+
+  describe "update note" do
+    test "converts object into Pleroma's format" do
+      mastodon_tags = [
+        %{
+          "icon" => %{
+            "mediaType" => "image/png",
+            "type" => "Image",
+            "url" => "https://somewhere.org/emoji/url/1.png"
+          },
+          "id" => "https://somewhere.org/emoji/1",
+          "name" => ":some_emoji:",
+          "type" => "Emoji",
+          "updated" => "2021-04-07T11:00:00Z"
+        }
+      ]
+
+      user = insert(:user)
+      note = insert(:note, user: user)
+
+      updated_note =
+        note.data
+        |> Map.put("content", "edited content")
+        |> Map.put("tag", mastodon_tags)
+
+      {:ok, update, _} = Builder.update(user, updated_note)
+
+      assert {:ok, _update, meta} = ObjectValidator.validate(update, [])
+
+      assert %{"emoji" => %{"some_emoji" => "https://somewhere.org/emoji/url/1.png"}} =
+               meta[:object_data]
+    end
+
+    test "returns no object_data in meta for a local Update" do
+      user = insert(:user)
+      note = insert(:note, user: user)
+
+      updated_note =
+        note.data
+        |> Map.put("content", "edited content")
+
+      {:ok, update, _} = Builder.update(user, updated_note)
+
+      assert {:ok, _update, meta} = ObjectValidator.validate(update, local: true)
+      assert is_nil(meta[:object_data])
+    end
+
+    test "returns object_data in meta for a remote Update" do
+      user = insert(:user)
+      note = insert(:note, user: user)
+
+      updated_note =
+        note.data
+        |> Map.put("content", "edited content")
+
+      {:ok, update, _} = Builder.update(user, updated_note)
+
+      assert {:ok, _update, meta} = ObjectValidator.validate(update, local: false)
+      assert meta[:object_data]
+
+      assert {:ok, _update, meta} = ObjectValidator.validate(update, [])
+      assert meta[:object_data]
+    end
+  end
+
+  describe "update with history" do
+    setup do
+      user = insert(:user)
+      {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"})
+      {:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"})
+      {:ok, external_rep} = Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data)
+      %{external_rep: external_rep}
+    end
+
+    test "edited note", %{external_rep: external_rep} do
+      {:ok, _validate_res, meta} = ObjectValidator.validate(external_rep, [])
+
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} =
+               meta[:object_data]
+    end
+
+    test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do
+      external_rep = put_in(external_rep, ["object", "formerRepresentations"], %{})
+
+      assert {:error, _} = ObjectValidator.validate(external_rep, [])
+    end
+
+    test "edited note, badly-formed history item", %{external_rep: external_rep} do
+      history_item =
+        Enum.at(external_rep["object"]["formerRepresentations"]["orderedItems"], 0)
+        |> Map.put("type", "Foo")
+
+      external_rep =
+        put_in(
+          external_rep,
+          ["object", "formerRepresentations", "orderedItems"],
+          [history_item]
+        )
+
+      assert {:error, _} = ObjectValidator.validate(external_rep, [])
+    end
   end
 end
index e542c06f587de417b44437c7232e723981750502..fa8171eabc4f5495a865ce72bd15993571138d3b 100644 (file)
@@ -123,7 +123,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
   describe "update users" do
     setup do
       user = insert(:user, local: false)
-      {:ok, update_data, []} = Builder.update(user, %{"id" => user.ap_id, "name" => "new name!"})
+
+      {:ok, update_data, []} =
+        Builder.update(user, %{"id" => user.ap_id, "type" => "Person", "name" => "new name!"})
+
       {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
 
       %{user: user, update_data: update_data, update: update}
@@ -145,6 +148,298 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
     end
   end
 
+  describe "update notes" do
+    setup do
+      make_time = fn ->
+        Pleroma.Web.ActivityPub.Utils.make_date()
+      end
+
+      user = insert(:user)
+      note = insert(:note, user: user, data: %{"published" => make_time.()})
+      _note_activity = insert(:note_activity, note: note)
+
+      updated_note =
+        note.data
+        |> Map.put("summary", "edited summary")
+        |> Map.put("content", "edited content")
+        |> Map.put("updated", make_time.())
+
+      {:ok, update_data, []} = Builder.update(user, updated_note)
+      {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+      %{
+        user: user,
+        note: note,
+        object_id: note.id,
+        update_data: update_data,
+        update: update,
+        updated_note: updated_note
+      }
+    end
+
+    test "it updates the note", %{
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      updated_time = updated_note["updated"]
+
+      new_note = Pleroma.Object.get_by_id(object_id)
+
+      assert %{
+               "summary" => "edited summary",
+               "content" => "edited content",
+               "updated" => ^updated_time
+             } = new_note.data
+    end
+
+    test "it rejects updates with no updated attribute in object", %{
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      old_note = Pleroma.Object.get_by_id(object_id)
+      updated_note = Map.drop(updated_note, ["updated"])
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      new_note = Pleroma.Object.get_by_id(object_id)
+      assert old_note.data == new_note.data
+    end
+
+    test "it rejects updates with updated attribute older than what we have in the original object",
+         %{
+           object_id: object_id,
+           update: update,
+           updated_note: updated_note
+         } do
+      old_note = Pleroma.Object.get_by_id(object_id)
+      {:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"])
+
+      updated_note =
+        Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, -10)))
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      new_note = Pleroma.Object.get_by_id(object_id)
+      assert old_note.data == new_note.data
+    end
+
+    test "it rejects updates with updated attribute older than the last Update", %{
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      old_note = Pleroma.Object.get_by_id(object_id)
+      {:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"])
+
+      updated_note =
+        Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, +10)))
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+
+      old_note = Pleroma.Object.get_by_id(object_id)
+      {:ok, update_time, _} = DateTime.from_iso8601(old_note.data["updated"])
+
+      updated_note =
+        Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(update_time, -5)))
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+
+      new_note = Pleroma.Object.get_by_id(object_id)
+      assert old_note.data == new_note.data
+    end
+
+    test "it updates using object_data", %{
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      updated_note = Map.put(updated_note, "summary", "mew mew")
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      new_note = Pleroma.Object.get_by_id(object_id)
+      assert %{"summary" => "mew mew", "content" => "edited content"} = new_note.data
+    end
+
+    test "it records the original note in formerRepresentations", %{
+      note: note,
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+      assert %{"summary" => "edited summary", "content" => "edited content"} = new_note
+
+      assert [Map.drop(note.data, ["id", "formerRepresentations"])] ==
+               new_note["formerRepresentations"]["orderedItems"]
+
+      assert new_note["formerRepresentations"]["totalItems"] == 1
+    end
+
+    test "it puts the original note at the front of formerRepresentations", %{
+      user: user,
+      note: note,
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      %{data: first_edit} = Pleroma.Object.get_by_id(object_id)
+
+      second_updated_note =
+        note.data
+        |> Map.put("summary", "edited summary 2")
+        |> Map.put("content", "edited content 2")
+        |> Map.put(
+          "updated",
+          first_edit["updated"]
+          |> DateTime.from_iso8601()
+          |> elem(1)
+          |> DateTime.add(10)
+          |> DateTime.to_iso8601()
+        )
+
+      {:ok, second_update_data, []} = Builder.update(user, second_updated_note)
+      {:ok, update, _meta} = ActivityPub.persist(second_update_data, local: true)
+      {:ok, _, _} = SideEffects.handle(update, object_data: second_updated_note)
+      %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+      assert %{"summary" => "edited summary 2", "content" => "edited content 2"} = new_note
+
+      original_version = Map.drop(note.data, ["id", "formerRepresentations"])
+      first_edit = Map.drop(first_edit, ["id", "formerRepresentations"])
+
+      assert [first_edit, original_version] ==
+               new_note["formerRepresentations"]["orderedItems"]
+
+      assert new_note["formerRepresentations"]["totalItems"] == 2
+    end
+
+    test "it does not prepend to formerRepresentations if no actual changes are made", %{
+      note: note,
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      %{data: first_edit} = Pleroma.Object.get_by_id(object_id)
+
+      updated_note =
+        updated_note
+        |> Map.put(
+          "updated",
+          first_edit["updated"]
+          |> DateTime.from_iso8601()
+          |> elem(1)
+          |> DateTime.add(10)
+          |> DateTime.to_iso8601()
+        )
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+      assert %{"summary" => "edited summary", "content" => "edited content"} = new_note
+
+      original_version = Map.drop(note.data, ["id", "formerRepresentations"])
+
+      assert [original_version] ==
+               new_note["formerRepresentations"]["orderedItems"]
+
+      assert new_note["formerRepresentations"]["totalItems"] == 1
+    end
+  end
+
+  describe "update questions" do
+    setup do
+      user = insert(:user)
+
+      question =
+        insert(:question,
+          user: user,
+          data: %{"published" => Pleroma.Web.ActivityPub.Utils.make_date()}
+        )
+
+      %{user: user, data: question.data, id: question.id}
+    end
+
+    test "allows updating choice count without generating edit history", %{
+      user: user,
+      data: data,
+      id: id
+    } do
+      new_choices =
+        data["oneOf"]
+        |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+      updated_question =
+        data
+        |> Map.put("oneOf", new_choices)
+        |> Map.put("updated", Pleroma.Web.ActivityPub.Utils.make_date())
+
+      {:ok, update_data, []} = Builder.update(user, updated_question)
+      {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+      %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+      assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+               new_question["oneOf"]
+
+      refute Map.has_key?(new_question, "formerRepresentations")
+    end
+
+    test "allows updating choice count without updated field", %{
+      user: user,
+      data: data,
+      id: id
+    } do
+      new_choices =
+        data["oneOf"]
+        |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+      updated_question =
+        data
+        |> Map.put("oneOf", new_choices)
+
+      {:ok, update_data, []} = Builder.update(user, updated_question)
+      {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+      %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+      assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+               new_question["oneOf"]
+
+      refute Map.has_key?(new_question, "formerRepresentations")
+    end
+
+    test "allows updating choice count with updated field same as the creation date", %{
+      user: user,
+      data: data,
+      id: id
+    } do
+      new_choices =
+        data["oneOf"]
+        |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+      updated_question =
+        data
+        |> Map.put("oneOf", new_choices)
+        |> Map.put("updated", data["published"])
+
+      {:ok, update_data, []} = Builder.update(user, updated_question)
+      {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+      %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+      assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+               new_question["oneOf"]
+
+      refute Map.has_key?(new_question, "formerRepresentations")
+    end
+  end
+
   describe "EmojiReact objects" do
     setup do
       poster = insert(:user)
index 24df5ea61c5d2f275340bc9bb595b388ce72fcaf..002042802ef73d4424810dfa8be572e4888b4118 100644 (file)
@@ -380,7 +380,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do
       clear_config([:instance, :federation_incoming_replies_max_depth], 10)
 
       {:ok, activity} = Transmogrifier.handle_incoming(data)
-
       object = Object.normalize(activity.data["object"])
 
       assert object.data["replies"] == items
index ae2fc067af9fb2f14a07d4a57f00b859dd8abc17..a1070848106ccc585a22149cbffe8fb20da69590 100644 (file)
@@ -301,6 +301,28 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
 
       assert url == "http://localhost:4001/emoji/dino%20walking.gif"
     end
+
+    test "Updates of Notes are handled" do
+      user = insert(:user)
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "everybody do the dinosaur :dinosaur:"})
+      {:ok, update} = CommonAPI.update(user, activity, %{status: "mew mew :blank:"})
+
+      {:ok, prepared} = Transmogrifier.prepare_outgoing(update.data)
+
+      assert %{
+               "content" => "mew mew :blank:",
+               "tag" => [%{"name" => ":blank:", "type" => "Emoji"}],
+               "formerRepresentations" => %{
+                 "orderedItems" => [
+                   %{
+                     "content" => "everybody do the dinosaur :dinosaur:",
+                     "tag" => [%{"name" => ":dinosaur:", "type" => "Emoji"}]
+                   }
+                 ]
+               }
+             } = prepared["object"]
+    end
   end
 
   describe "user upgrade" do
@@ -564,4 +586,43 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       assert Transmogrifier.fix_attachments(object) == expected
     end
   end
+
+  describe "prepare_object/1" do
+    test "it processes history" do
+      original = %{
+        "formerRepresentations" => %{
+          "orderedItems" => [
+            %{
+              "generator" => %{},
+              "emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
+            }
+          ]
+        }
+      }
+
+      processed = Transmogrifier.prepare_object(original)
+
+      history_item = Enum.at(processed["formerRepresentations"]["orderedItems"], 0)
+
+      refute Map.has_key?(history_item, "generator")
+
+      assert [%{"name" => ":blobcat:"}] = history_item["tag"]
+    end
+
+    test "it works when there is no or bad history" do
+      original = %{
+        "formerRepresentations" => %{
+          "items" => [
+            %{
+              "generator" => %{},
+              "emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
+            }
+          ]
+        }
+      }
+
+      processed = Transmogrifier.prepare_object(original)
+      assert processed["formerRepresentations"] == original["formerRepresentations"]
+    end
+  end
 end
index 0d88303e34d8a3913095fe8c2d1641cd461e8f5a..e45af3aec35cc233580870c97a7b9d412c8ac981 100644 (file)
@@ -229,29 +229,6 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do
     end
   end
 
-  describe "update_follow_state/2" do
-    test "updates the state of the given follow activity" do
-      user = insert(:user, is_locked: true)
-      follower = insert(:user)
-
-      {:ok, _, _, follow_activity} = CommonAPI.follow(follower, user)
-      {:ok, _, _, follow_activity_two} = CommonAPI.follow(follower, user)
-
-      data =
-        follow_activity_two.data
-        |> Map.put("state", "accept")
-
-      cng = Ecto.Changeset.change(follow_activity_two, data: data)
-
-      {:ok, follow_activity_two} = Repo.update(cng)
-
-      {:ok, follow_activity_two} = Utils.update_follow_state(follow_activity_two, "reject")
-
-      assert refresh_record(follow_activity).data["state"] == "pending"
-      assert refresh_record(follow_activity_two).data["state"] == "reject"
-    end
-  end
-
   describe "update_element_in_object/3" do
     test "updates likes" do
       user = insert(:user)
index fa751bf60654db60536499551175a94c033b2c69..2b7a34be23f6ef12ef994275a39de6cbdd56276c 100644 (file)
@@ -1058,24 +1058,23 @@ defmodule Pleroma.Web.CommonAPITest do
       refute User.subscribed_to?(follower, followed)
     end
 
-    test "cancels a pending follow for a local user" do
+    test "removes a pending follow for a local user" do
       follower = insert(:user)
       followed = insert(:user, is_locked: true)
 
-      assert {:ok, follower, followed, %{id: activity_id, data: %{"state" => "pending"}}} =
+      assert {:ok, follower, followed, %{id: _activity_id, data: %{"state" => "pending"}}} =
                CommonAPI.follow(follower, followed)
 
       assert User.get_follow_state(follower, followed) == :follow_pending
       assert {:ok, follower} = CommonAPI.unfollow(follower, followed)
       assert User.get_follow_state(follower, followed) == nil
 
-      assert %{id: ^activity_id, data: %{"state" => "cancelled"}} =
-               Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed)
+      assert is_nil(Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed))
 
       assert %{
                data: %{
                  "type" => "Undo",
-                 "object" => %{"type" => "Follow", "state" => "cancelled"}
+                 "object" => %{"type" => "Follow"}
                }
              } = Pleroma.Web.ActivityPub.Utils.fetch_latest_undo(follower)
     end
@@ -1084,20 +1083,19 @@ defmodule Pleroma.Web.CommonAPITest do
       follower = insert(:user)
       followed = insert(:user, is_locked: true, local: false, ap_enabled: true)
 
-      assert {:ok, follower, followed, %{id: activity_id, data: %{"state" => "pending"}}} =
+      assert {:ok, follower, followed, %{id: _activity_id, data: %{"state" => "pending"}}} =
                CommonAPI.follow(follower, followed)
 
       assert User.get_follow_state(follower, followed) == :follow_pending
       assert {:ok, follower} = CommonAPI.unfollow(follower, followed)
       assert User.get_follow_state(follower, followed) == nil
 
-      assert %{id: ^activity_id, data: %{"state" => "cancelled"}} =
-               Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed)
+      assert is_nil(Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed))
 
       assert %{
                data: %{
                  "type" => "Undo",
-                 "object" => %{"type" => "Follow", "state" => "cancelled"}
+                 "object" => %{"type" => "Follow"}
                }
              } = Pleroma.Web.ActivityPub.Utils.fetch_latest_undo(follower)
     end
@@ -1315,4 +1313,128 @@ defmodule Pleroma.Web.CommonAPITest do
       end
     end
   end
+
+  describe "update/3" do
+    test "updates a post" do
+      user = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1"})
+
+      {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+      updated_object = Object.normalize(updated)
+      assert updated_object.data["content"] == "updated 2"
+      assert Map.get(updated_object.data, "summary", "") == ""
+      assert Map.has_key?(updated_object.data, "updated")
+    end
+
+    test "does not change visibility" do
+      user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1", visibility: "private"})
+
+      {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+      updated_object = Object.normalize(updated)
+      assert updated_object.data["content"] == "updated 2"
+      assert Map.get(updated_object.data, "summary", "") == ""
+      assert Visibility.get_visibility(updated_object) == "private"
+      assert Visibility.get_visibility(updated) == "private"
+    end
+
+    test "updates a post with emoji" do
+      [{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
+
+      user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
+
+      {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"})
+
+      updated_object = Object.normalize(updated)
+      assert updated_object.data["content"] == "updated 2 :#{emoji2}:"
+      assert %{^emoji2 => _} = updated_object.data["emoji"]
+    end
+
+    test "updates a post with emoji and federate properly" do
+      [{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
+
+      user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
+
+      clear_config([:instance, :federating], true)
+
+      with_mock Pleroma.Web.Federator,
+        publish: fn _p -> nil end do
+        {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"})
+
+        assert updated.data["object"]["content"] == "updated 2 :#{emoji2}:"
+        assert %{^emoji2 => _} = updated.data["object"]["emoji"]
+
+        assert called(Pleroma.Web.Federator.publish(updated))
+      end
+    end
+
+    test "editing a post that copied a remote title with remote emoji should keep that emoji" do
+      remote_emoji_uri = "https://remote.org/emoji.png"
+
+      note =
+        insert(
+          :note,
+          data: %{
+            "summary" => ":remoteemoji:",
+            "emoji" => %{
+              "remoteemoji" => remote_emoji_uri
+            },
+            "tag" => [
+              %{
+                "type" => "Emoji",
+                "name" => "remoteemoji",
+                "icon" => %{"url" => remote_emoji_uri}
+              }
+            ]
+          }
+        )
+
+      note_activity = insert(:note_activity, note: note)
+
+      user = insert(:user)
+
+      {:ok, reply} =
+        CommonAPI.post(user, %{
+          status: "reply",
+          spoiler_text: ":remoteemoji:",
+          in_reply_to_id: note_activity.id
+        })
+
+      assert reply.object.data["emoji"]["remoteemoji"] == remote_emoji_uri
+
+      {:ok, edit} =
+        CommonAPI.update(user, reply, %{status: "reply mew mew", spoiler_text: ":remoteemoji:"})
+
+      edited_note = Pleroma.Object.normalize(edit)
+
+      assert edited_note.data["emoji"]["remoteemoji"] == remote_emoji_uri
+    end
+
+    test "respects MRF" do
+      user = insert(:user)
+
+      clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy])
+      clear_config([:mrf_keyword, :replace], [{"updated", "mewmew"}])
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "updated 1"})
+      assert Object.normalize(activity).data["summary"] == "mewmew 1"
+
+      {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+      updated_object = Object.normalize(updated)
+      assert updated_object.data["content"] == "mewmew 2"
+      assert Map.get(updated_object.data, "summary", "") == ""
+      assert Map.has_key?(updated_object.data, "updated")
+    end
+  end
 end
diff --git a/test/pleroma/web/masto_fe_controller_test.exs b/test/pleroma/web/masto_fe_controller_test.exs
new file mode 100644 (file)
index 0000000..924b453
--- /dev/null
@@ -0,0 +1,38 @@
+defmodule Pleroma.Web.MastoFEControllerTest do
+  use Pleroma.Web.ConnCase, async: true
+  alias Pleroma.Web.MastodonAPI.AuthController
+
+  describe "index/2 (main page)" do
+    test "GET /web/ (glitch-soc)" do
+      clear_config([:frontends, :mastodon], %{"name" => "mastodon-fe"})
+
+      {:ok, masto_app} = AuthController.local_mastofe_app()
+      user = Pleroma.Factory.insert(:user)
+      token = Pleroma.Factory.insert(:oauth_token, app: masto_app, user: user)
+      %{conn: conn} = oauth_access(["read", "write"], oauth_token: token, user: user)
+
+      resp =
+        conn
+        |> get("/web/getting-started")
+        |> html_response(200)
+
+      assert resp =~ "glitch"
+    end
+
+    test "GET /web/ (fedibird)" do
+      clear_config([:frontends, :mastodon], %{"name" => "fedibird-fe"})
+
+      {:ok, masto_app} = AuthController.local_mastofe_app()
+      user = Pleroma.Factory.insert(:user)
+      token = Pleroma.Factory.insert(:oauth_token, app: masto_app, user: user)
+      %{conn: conn} = oauth_access(["read", "write"], oauth_token: token, user: user)
+
+      resp =
+        conn
+        |> get("/web/getting-started")
+        |> html_response(200)
+
+      refute resp =~ "glitch"
+    end
+  end
+end
index 90801a90a86450410cb49cfe31bded6dd60f4e33..bc3d358193fcb37e8ce49ee1a9217c20f5cf17ef 100644 (file)
@@ -10,10 +10,11 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
   import Pleroma.Factory
 
   test "get instance information", %{conn: conn} do
+    clear_config([:instance, :languages], ["en", "ja"])
     conn = get(conn, "/api/v1/instance")
     assert result = json_response_and_validate_schema(conn, 200)
-
     email = Pleroma.Config.get([:instance, :email])
+
     thumbnail = Pleroma.Web.Endpoint.url() <> Pleroma.Config.get([:instance, :instance_thumbnail])
     background = Pleroma.Web.Endpoint.url() <> Pleroma.Config.get([:instance, :background_image])
 
@@ -29,7 +30,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
              },
              "stats" => _,
              "thumbnail" => from_config_thumbnail,
-             "languages" => _,
+             "languages" => ["en", "ja"],
              "registrations" => _,
              "approval_required" => _,
              "poll_limits" => _,
index ea168f6c5183b5b3fd63ada2d485e2f7f19240c4..ea6ace69f5ac60529e6881b22651be1511d85725 100644 (file)
@@ -2071,4 +2071,284 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
                |> json_response_and_validate_schema(422)
     end
   end
+
+  describe "get status history" do
+    setup do
+      %{conn: build_conn()}
+    end
+
+    test "unedited post", %{conn: conn} do
+      activity = insert(:note_activity)
+
+      conn = get(conn, "/api/v1/statuses/#{activity.id}/history")
+
+      assert [_] = json_response_and_validate_schema(conn, 200)
+    end
+
+    test "edited post", %{conn: conn} do
+      note =
+        insert(
+          :note,
+          data: %{
+            "formerRepresentations" => %{
+              "type" => "OrderedCollection",
+              "orderedItems" => [
+                %{
+                  "type" => "Note",
+                  "content" => "mew mew 2",
+                  "summary" => "title 2"
+                },
+                %{
+                  "type" => "Note",
+                  "content" => "mew mew 1",
+                  "summary" => "title 1"
+                }
+              ],
+              "totalItems" => 2
+            }
+          }
+        )
+
+      activity = insert(:note_activity, note: note)
+
+      conn = get(conn, "/api/v1/statuses/#{activity.id}/history")
+
+      assert [%{"spoiler_text" => "title 1"}, %{"spoiler_text" => "title 2"}, _] =
+               json_response_and_validate_schema(conn, 200)
+    end
+  end
+
+  describe "translating statuses" do
+    setup do
+      clear_config([:translator, :enabled], true)
+      clear_config([:translator, :module], Pleroma.Akkoma.Translators.DeepL)
+      clear_config([:deepl, :api_key], "deepl_api_key")
+      oauth_access(["read:statuses"])
+    end
+
+    test "listing languages", %{conn: conn} do
+      Tesla.Mock.mock_global(fn
+        %{method: :get, url: "https://api-free.deepl.com/v2/languages?type=source"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!([
+                %{language: "en", name: "English"}
+              ])
+          }
+
+        %{method: :get, url: "https://api-free.deepl.com/v2/languages?type=target"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!([
+                %{language: "ja", name: "Japanese"}
+              ])
+          }
+      end)
+
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> get("/api/v1/akkoma/translation/languages")
+
+      response = json_response_and_validate_schema(conn, 200)
+
+      assert %{
+               "source" => [%{"code" => "en", "name" => "English"}],
+               "target" => [%{"code" => "ja", "name" => "Japanese"}]
+             } = response
+    end
+
+    test "should return text and detected language", %{conn: conn} do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock_global(fn
+        %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "Tell me, for whom do you fight?",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      user = insert(:user)
+      {:ok, to_translate} = CommonAPI.post(user, %{status: "何のために闘う?"})
+
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> get("/api/v1/statuses/#{to_translate.id}/translations/en")
+
+      response = json_response_and_validate_schema(conn, 200)
+
+      assert response["text"] == "Tell me, for whom do you fight?"
+      assert response["detected_language"] == "ja"
+    end
+
+    test "should not allow translating of statuses you cannot see", %{conn: conn} do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock_global(fn
+        %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "Tell me, for whom do you fight?",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      user = insert(:user)
+      {:ok, to_translate} = CommonAPI.post(user, %{status: "何のために闘う?", visibility: "private"})
+
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> get("/api/v1/statuses/#{to_translate.id}/translations/en")
+
+      json_response_and_validate_schema(conn, 404)
+    end
+  end
+
+  describe "get status source" do
+    setup do
+      %{conn: build_conn()}
+    end
+
+    test "it returns the source", %{conn: conn} do
+      user = insert(:user)
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+      conn = get(conn, "/api/v1/statuses/#{activity.id}/source")
+
+      id = activity.id
+
+      assert %{"id" => ^id, "text" => "mew mew #abc", "spoiler_text" => "#def"} =
+               json_response_and_validate_schema(conn, 200)
+    end
+  end
+
+  describe "update status" do
+    setup do
+      oauth_access(["write:statuses"])
+    end
+
+    test "it updates the status" do
+      %{conn: conn, user: user} = oauth_access(["write:statuses", "read:statuses"])
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+      conn
+      |> get("/api/v1/statuses/#{activity.id}")
+      |> json_response_and_validate_schema(200)
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/statuses/#{activity.id}", %{
+          "status" => "edited",
+          "spoiler_text" => "lol"
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert response["content"] == "edited"
+      assert response["spoiler_text"] == "lol"
+
+      response =
+        conn
+        |> get("/api/v1/statuses/#{activity.id}")
+        |> json_response_and_validate_schema(200)
+
+      assert response["content"] == "edited"
+      assert response["spoiler_text"] == "lol"
+    end
+
+    test "it updates the attachments", %{conn: conn, user: user} do
+      attachment = insert(:attachment, user: user)
+      attachment_id = to_string(attachment.id)
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/statuses/#{activity.id}", %{
+          "status" => "mew mew #abc",
+          "spoiler_text" => "#def",
+          "media_ids" => [attachment_id]
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert [%{"id" => ^attachment_id}] = response["media_attachments"]
+    end
+
+    test "it does not update visibility", %{conn: conn, user: user} do
+      {:ok, activity} =
+        CommonAPI.post(user, %{
+          status: "mew mew #abc",
+          spoiler_text: "#def",
+          visibility: "private"
+        })
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/statuses/#{activity.id}", %{
+          "status" => "edited",
+          "spoiler_text" => "lol"
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert response["visibility"] == "private"
+    end
+
+    test "it refuses to update when original post is not by the user", %{conn: conn} do
+      another_user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(another_user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/statuses/#{activity.id}", %{
+        "status" => "edited",
+        "spoiler_text" => "lol"
+      })
+      |> json_response_and_validate_schema(:forbidden)
+    end
+
+    test "it returns 404 if the user cannot see the post", %{conn: conn} do
+      another_user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(another_user, %{
+          status: "mew mew #abc",
+          spoiler_text: "#def",
+          visibility: "private"
+        })
+
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/statuses/#{activity.id}", %{
+        "status" => "edited",
+        "spoiler_text" => "lol"
+      })
+      |> json_response_and_validate_schema(:not_found)
+    end
+  end
 end
index 803b1f438308355e4c6af57522a3d6bac0660a36..64d2c8a2eac7a0a1db1d665e55c767444ede2801 100644 (file)
@@ -285,6 +285,32 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
     test_notifications_rendering([notification], moderator_user, [expected])
   end
 
+  test "Edit notification" do
+    user = insert(:user)
+    repeat_user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{status: "mew"})
+    {:ok, _} = CommonAPI.repeat(activity.id, repeat_user)
+    {:ok, update} = CommonAPI.update(user, activity, %{status: "mew mew"})
+
+    user = Pleroma.User.get_by_ap_id(user.ap_id)
+    activity = Pleroma.Activity.normalize(activity)
+    update = Pleroma.Activity.normalize(update)
+
+    {:ok, [notification]} = Notification.create_notifications(update)
+
+    expected = %{
+      id: to_string(notification.id),
+      pleroma: %{is_seen: false, is_muted: false},
+      type: "update",
+      account: AccountView.render("show.json", %{user: user, for: repeat_user}),
+      created_at: Utils.to_masto_date(notification.inserted_at),
+      status: StatusView.render("show.json", %{activity: activity, for: repeat_user})
+    }
+
+    test_notifications_rendering([notification], repeat_user, [expected])
+  end
+
   test "muted notification" do
     user = insert(:user)
     another_user = insert(:user)
index fb3255927a97aa591467bead785a9ef8813824fb..b3f0a178155a7ab902b8d206a672c54ce42ec1bb 100644 (file)
@@ -44,14 +44,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
 
     assert status[:pleroma][:emoji_reactions] == [
-             %{name: "☕", count: 2, me: false, url: nil},
+             %{name: "☕", count: 2, me: false, url: nil, account_ids: [other_user.id, user.id]},
              %{
                count: 2,
                me: false,
                name: "dinosaur",
-               url: "http://localhost:4001/emoji/dino walking.gif"
+               url: "http://localhost:4001/emoji/dino walking.gif",
+               account_ids: [other_user.id, user.id]
              },
-             %{name: "🍵", count: 1, me: false, url: nil}
+             %{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]}
            ]
 
     status = StatusView.render("show.json", activity: activity, for: user)
@@ -59,14 +60,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
 
     assert status[:pleroma][:emoji_reactions] == [
-             %{name: "☕", count: 2, me: true, url: nil},
+             %{name: "☕", count: 2, me: true, url: nil, account_ids: [other_user.id, user.id]},
              %{
                count: 2,
                me: true,
                name: "dinosaur",
-               url: "http://localhost:4001/emoji/dino walking.gif"
+               url: "http://localhost:4001/emoji/dino walking.gif",
+               account_ids: [other_user.id, user.id]
              },
-             %{name: "🍵", count: 1, me: false, url: nil}
+             %{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]}
            ]
   end
 
@@ -82,7 +84,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     status = StatusView.render("show.json", activity: activity, for: user)
 
     assert status[:pleroma][:emoji_reactions] == [
-             %{name: "☕", count: 1, me: true, url: nil}
+             %{name: "☕", count: 1, me: true, url: nil, account_ids: [user.id]}
            ]
   end
 
@@ -102,7 +104,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     status = StatusView.render("show.json", activity: activity)
 
     assert status[:pleroma][:emoji_reactions] == [
-             %{name: "☕", count: 1, me: false, url: nil}
+             %{name: "☕", count: 1, me: false, url: nil, account_ids: [other_user.id]}
            ]
 
     status = StatusView.render("show.json", activity: activity, for: user)
@@ -114,19 +116,25 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     status = StatusView.render("show.json", activity: activity)
 
     assert status[:pleroma][:emoji_reactions] == [
-             %{name: "☕", count: 2, me: false, url: nil}
+             %{
+               name: "☕",
+               count: 2,
+               me: false,
+               url: nil,
+               account_ids: [third_user.id, other_user.id]
+             }
            ]
 
     status = StatusView.render("show.json", activity: activity, for: user)
 
     assert status[:pleroma][:emoji_reactions] == [
-             %{name: "☕", count: 1, me: false, url: nil}
+             %{name: "☕", count: 1, me: false, url: nil, account_ids: [third_user.id]}
            ]
 
     status = StatusView.render("show.json", activity: activity, for: other_user)
 
     assert status[:pleroma][:emoji_reactions] == [
-             %{name: "☕", count: 1, me: true, url: nil}
+             %{name: "☕", count: 1, me: true, url: nil, account_ids: [other_user.id]}
            ]
   end
 
@@ -259,6 +267,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
       content: HTML.filter_tags(object_data["content"]),
       text: nil,
       created_at: created_at,
+      edited_at: nil,
       reblogs_count: 0,
       replies_count: 0,
       favourites_count: 0,
@@ -272,6 +281,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
       spoiler_text: HTML.filter_tags(object_data["summary"]),
       visibility: "public",
       media_attachments: [],
+      emoji_reactions: [],
       mentions: [],
       tags: [
         %{
@@ -419,6 +429,34 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     assert is_nil(status.quote)
   end
 
+  test "a quote from a user we block" do
+    user = insert(:user)
+    other_user = insert(:user)
+    blocked_user = insert(:user)
+
+    {:ok, _relationship} = User.block(user, blocked_user)
+
+    {:ok, activity} = CommonAPI.post(blocked_user, %{status: ":< i am ANGERY"})
+    {:ok, quote_activity} = CommonAPI.post(other_user, %{status: "hehe", quote_id: activity.id})
+
+    status = StatusView.render("show.json", %{activity: quote_activity, for: user})
+    assert is_nil(status.quote)
+  end
+
+  test "a quote from a user we mute" do
+    user = insert(:user)
+    other_user = insert(:user)
+    blocked_user = insert(:user)
+
+    {:ok, _relationship} = User.mute(user, blocked_user)
+
+    {:ok, activity} = CommonAPI.post(blocked_user, %{status: ":< i am ANGERY"})
+    {:ok, quote_activity} = CommonAPI.post(other_user, %{status: "hehe", quote_id: activity.id})
+
+    status = StatusView.render("show.json", %{activity: quote_activity, for: user})
+    assert is_nil(status.quote)
+  end
+
   test "contains mentions" do
     user = insert(:user)
     mentioned = insert(:user)
@@ -751,4 +789,55 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     status = StatusView.render("show.json", activity: visible, for: poster)
     assert status.pleroma.parent_visible
   end
+
+  test "it shows edited_at" do
+    poster = insert(:user)
+
+    {:ok, post} = CommonAPI.post(poster, %{status: "hey"})
+
+    status = StatusView.render("show.json", activity: post)
+    refute status.edited_at
+
+    {:ok, _} = CommonAPI.update(poster, post, %{status: "mew mew"})
+    edited = Pleroma.Activity.normalize(post)
+
+    status = StatusView.render("show.json", activity: edited)
+    assert status.edited_at
+  end
+
+  test "with a source object" do
+    note =
+      insert(:note,
+        data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
+      )
+
+    activity = insert(:note_activity, note: note)
+
+    status = StatusView.render("show.json", activity: activity, with_source: true)
+    assert status.text == "object source"
+  end
+
+  describe "source.json" do
+    test "with a source object, renders both source and content type" do
+      note =
+        insert(:note,
+          data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
+        )
+
+      activity = insert(:note_activity, note: note)
+
+      status = StatusView.render("source.json", activity: activity)
+      assert status.text == "object source"
+      assert status.content_type == "text/markdown"
+    end
+
+    test "with a source string, renders source and put text/plain as the content type" do
+      note = insert(:note, data: %{"source" => "string source"})
+      activity = insert(:note_activity, note: note)
+
+      status = StatusView.render("source.json", activity: activity)
+      assert status.text == "string source"
+      assert status.content_type == "text/plain"
+    end
+  end
 end
index 074bd2e2f8edde049ff48c1b0bb82f1c2e9c6ac5..c99d11596f250552ca679d901d880a47932c656d 100644 (file)
@@ -3,7 +3,7 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.Metadata.UtilsTest do
-  use Pleroma.DataCase, async: true
+  use Pleroma.DataCase, async: false
   import Pleroma.Factory
   alias Pleroma.Web.Metadata.Utils
 
@@ -22,6 +22,20 @@ defmodule Pleroma.Web.Metadata.UtilsTest do
 
       assert Utils.scrub_html_and_truncate(note) == "Pleroma's really cool!"
     end
+
+    test "it does not return old content after editing" do
+      user = insert(:user)
+
+      {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew #def"})
+
+      object = Pleroma.Object.normalize(activity)
+      assert Utils.scrub_html_and_truncate(object) == "mew mew #def"
+
+      {:ok, update} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "mew mew #abc"})
+      update = Pleroma.Activity.normalize(update)
+      object = Pleroma.Object.normalize(update)
+      assert Utils.scrub_html_and_truncate(object) == "mew mew #abc"
+    end
   end
 
   describe "scrub_html_and_truncate/2" do
index 0fdd5b8e9fb853e6cd2f4a84ef6eb1ad39030292..5a1258ec31b460302dfcb1d04be838470ef8d8a7 100644 (file)
@@ -494,6 +494,129 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
       assert html_response(conn, 200) =~ ~s(type="submit")
     end
 
+    test "allows access if the user has a prior authorization but is authenticated with another client",
+         %{
+           app: app,
+           conn: conn
+         } do
+      user = insert(:user)
+      token = insert(:oauth_token, app: app, user: user)
+
+      other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
+      authorization = insert(:oauth_authorization, user: user, app: other_app)
+      _reusable_token = insert(:oauth_token, app: other_app, user: user)
+
+      conn =
+        conn
+        |> AuthHelper.put_session_token(token.token)
+        |> AuthHelper.put_session_user(user.id)
+        |> get(
+          "/oauth/authorize",
+          %{
+            "response_type" => "code",
+            "client_id" => other_app.client_id,
+            "redirect_uri" => OAuthController.default_redirect_uri(other_app),
+            "scope" => "read"
+          }
+        )
+
+      assert URI.decode(redirected_to(conn)) ==
+               "https://other_redirect.url?code=#{authorization.token}"
+    end
+
+    test "renders login page if the user has an authorization but no token",
+         %{
+           app: app,
+           conn: conn
+         } do
+      user = insert(:user)
+      token = insert(:oauth_token, app: app, user: user)
+
+      other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
+      _authorization = insert(:oauth_authorization, user: user, app: other_app)
+
+      conn =
+        conn
+        |> AuthHelper.put_session_token(token.token)
+        |> AuthHelper.put_session_user(user.id)
+        |> get(
+          "/oauth/authorize",
+          %{
+            "response_type" => "code",
+            "client_id" => other_app.client_id,
+            "redirect_uri" => OAuthController.default_redirect_uri(other_app),
+            "scope" => "read"
+          }
+        )
+
+      assert html_response(conn, 200) =~ ~s(type="submit")
+    end
+
+    test "does not reuse other people's tokens",
+         %{
+           app: app,
+           conn: conn
+         } do
+      user = insert(:user)
+      other_user = insert(:user)
+      token = insert(:oauth_token, app: app, user: user)
+
+      other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
+      _authorization = insert(:oauth_authorization, user: other_user, app: other_app)
+      _reusable_token = insert(:oauth_token, app: other_app, user: other_user)
+
+      conn =
+        conn
+        |> AuthHelper.put_session_token(token.token)
+        |> AuthHelper.put_session_user(user.id)
+        |> get(
+          "/oauth/authorize",
+          %{
+            "response_type" => "code",
+            "client_id" => other_app.client_id,
+            "redirect_uri" => OAuthController.default_redirect_uri(other_app),
+            "scope" => "read"
+          }
+        )
+
+      assert html_response(conn, 200) =~ ~s(type="submit")
+    end
+
+    test "does not reuse expired tokens",
+         %{
+           app: app,
+           conn: conn
+         } do
+      user = insert(:user)
+      token = insert(:oauth_token, app: app, user: user)
+
+      other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
+      _authorization = insert(:oauth_authorization, user: user, app: other_app)
+
+      _reusable_token =
+        insert(:oauth_token,
+          app: other_app,
+          user: user,
+          valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -100)
+        )
+
+      conn =
+        conn
+        |> AuthHelper.put_session_token(token.token)
+        |> AuthHelper.put_session_user(user.id)
+        |> get(
+          "/oauth/authorize",
+          %{
+            "response_type" => "code",
+            "client_id" => other_app.client_id,
+            "redirect_uri" => OAuthController.default_redirect_uri(other_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 65bb22e274b8824ac18afa87d1b52ea9b5315929..6864b37cb5d2989e0aa56b39266a67e803bec03a 100644 (file)
@@ -17,26 +17,40 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
     user = insert(:user)
     other_user = insert(:user)
 
-    {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+    note = insert(:note, user: user, data: %{"reactions" => [["👍", [other_user.ap_id], nil]]})
+    activity = insert(:note_activity, note: note, user: user)
 
     result =
       conn
       |> assign(:user, other_user)
       |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
-      |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/")
+      |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/\u26A0")
       |> json_response_and_validate_schema(200)
 
-    # We return the status, but this our implementation detail.
     assert %{"id" => id} = result
     assert to_string(activity.id) == id
 
     assert result["pleroma"]["emoji_reactions"] == [
-             %{"name" => "☕", "count" => 1, "me" => true, "url" => nil}
+             %{
+               "name" => "👍",
+               "count" => 1,
+               "me" => true,
+               "url" => nil,
+               "account_ids" => [other_user.id]
+             },
+             %{
+               "name" => "\u26A0\uFE0F",
+               "count" => 1,
+               "me" => true,
+               "url" => nil,
+               "account_ids" => [other_user.id]
+             }
            ]
 
     {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
 
     ObanHelpers.perform_all()
+
     # Reacting with a custom emoji
     result =
       conn
@@ -45,7 +59,6 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
       |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:dinosaur:")
       |> json_response_and_validate_schema(200)
 
-    # We return the status, but this our implementation detail.
     assert %{"id" => id} = result
     assert to_string(activity.id) == id
 
@@ -54,10 +67,51 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
                "name" => "dinosaur",
                "count" => 1,
                "me" => true,
-               "url" => "http://localhost:4001/emoji/dino walking.gif"
+               "url" => "http://localhost:4001/emoji/dino walking.gif",
+               "account_ids" => [other_user.id]
+             }
+           ]
+
+    # Reacting with a remote emoji
+    note =
+      insert(:note,
+        user: user,
+        data: %{"reactions" => [["wow", [other_user.ap_id], "https://remote/emoji/wow"]]}
+      )
+
+    activity = insert(:note_activity, note: note, user: user)
+
+    result =
+      conn
+      |> assign(:user, user)
+      |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:statuses"]))
+      |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+      |> json_response(200)
+
+    assert result["pleroma"]["emoji_reactions"] == [
+             %{
+               "name" => "wow@remote",
+               "count" => 2,
+               "me" => true,
+               "url" => "https://remote/emoji/wow",
+               "account_ids" => [user.id, other_user.id]
              }
            ]
 
+    # Reacting with a remote custom emoji that hasn't been reacted with yet
+    note =
+      insert(:note,
+        user: user
+      )
+
+    activity = insert(:note_activity, note: note, user: user)
+
+    assert conn
+           |> assign(:user, user)
+           |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:statuses"]))
+           |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+           |> json_response(400)
+
     # Reacting with a non-emoji
     assert conn
            |> assign(:user, other_user)
@@ -70,10 +124,22 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
     user = insert(:user)
     other_user = insert(:user)
 
-    {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+    note =
+      insert(:note,
+        user: user,
+        data: %{"reactions" => [["wow", [user.ap_id], "https://remote/emoji/wow"]]}
+      )
+
+    activity = insert(:note_activity, note: note, user: user)
+
+    ObanHelpers.perform_all()
+
     {:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
     {:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, ":dinosaur:")
 
+    {:ok, _reaction_activity} =
+      CommonAPI.react_with_emoji(activity.id, other_user, ":wow@remote:")
+
     ObanHelpers.perform_all()
 
     result =
@@ -100,7 +166,32 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do
 
     object = Object.get_by_ap_id(activity.data["object"])
 
-    assert object.data["reaction_count"] == 0
+    assert object.data["reaction_count"] == 2
+
+    # Remove custom remote emoji
+    result =
+      conn
+      |> assign(:user, other_user)
+      |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
+      |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+      |> json_response(200)
+
+    assert result["pleroma"]["emoji_reactions"] == [
+             %{
+               "name" => "wow@remote",
+               "count" => 1,
+               "me" => false,
+               "url" => "https://remote/emoji/wow",
+               "account_ids" => [user.id]
+             }
+           ]
+
+    # Remove custom remote emoji that hasn't been reacted with yet
+    assert conn
+           |> assign(:user, other_user)
+           |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
+           |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/:zoop@remote:")
+           |> json_response(400)
   end
 
   test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do
index 02e8b309202524bfca150e318b9877d4e0423ce5..8ce9565103344378ad36001e36ab4f38c0ecd040 100644 (file)
@@ -86,10 +86,12 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do
     test "aliases redirected /object endpoints", _ do
       obj = insert(:note)
       act = insert(:note_activity, note: obj)
-      params = %{"actor" => "http://mastodon.example.org/users/admin"}
+      params = %{"actor" => "someparam"}
       path = URI.parse(obj.data["id"]).path
       conn = build_conn(:get, path, params)
-      assert ["/notice/#{act.id}"] == HTTPSignaturePlug.route_aliases(conn)
+
+      assert ["/notice/#{act.id}", "/notice/#{act.id}?actor=someparam"] ==
+               HTTPSignaturePlug.route_aliases(conn)
     end
   end
 end
index 841db0e91144bb811ac72b6d030979903890be18..8e2ab5016cf4ebb19e8b708f46b7619d1de5771a 100644 (file)
@@ -157,7 +157,8 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("user", user, oauth_token)
       {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
 
-      assert_receive {:render_with_user, _, _, ^activity}
+      stream_name = "user:#{user.id}"
+      assert_receive {:render_with_user, _, _, ^activity, ^stream_name}
       refute Streamer.filtered_by_user?(user, activity)
     end
 
@@ -168,7 +169,11 @@ defmodule Pleroma.Web.StreamerTest do
       {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
       {:ok, announce} = CommonAPI.repeat(activity.id, user)
 
-      assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce}
+      stream_name = "user:#{user.id}"
+
+      assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce,
+                      ^stream_name}
+
       refute Streamer.filtered_by_user?(user, announce)
     end
 
@@ -221,7 +226,11 @@ defmodule Pleroma.Web.StreamerTest do
       {:ok, %Pleroma.Activity{data: _data, local: false} = announce} =
         Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(data)
 
-      assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce}
+      stream_name = "user:#{user.id}"
+
+      assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce,
+                      ^stream_name}
+
       refute Streamer.filtered_by_user?(user, announce)
     end
 
@@ -233,7 +242,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("user", user, oauth_token)
       Streamer.stream("user", notify)
 
-      assert_receive {:render_with_user, _, _, ^notify}
+      assert_receive {:render_with_user, _, _, ^notify, "user"}
       refute Streamer.filtered_by_user?(user, notify)
     end
 
@@ -245,7 +254,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
       Streamer.stream("user:notification", notify)
 
-      assert_receive {:render_with_user, _, _, ^notify}
+      assert_receive {:render_with_user, _, _, ^notify, "user:notification"}
       refute Streamer.filtered_by_user?(user, notify)
     end
 
@@ -291,7 +300,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
       {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
 
-      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert_receive {:render_with_user, _, "notification.json", notif, "user:notification"}
       assert notif.activity.id == favorite_activity.id
       refute Streamer.filtered_by_user?(user, notif)
     end
@@ -320,7 +329,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
       {:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user2, user)
 
-      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert_receive {:render_with_user, _, "notification.json", notif, "user:notification"}
       assert notif.activity.id == follow_activity.id
       refute Streamer.filtered_by_user?(user, notif)
     end
@@ -374,6 +383,33 @@ defmodule Pleroma.Web.StreamerTest do
                "state" => "follow_accept"
              } = Jason.decode!(payload)
     end
+
+    test "it streams edits in the 'user' stream", %{user: user, token: oauth_token} do
+      sender = insert(:user)
+      {:ok, _, _, _} = CommonAPI.follow(user, sender)
+
+      {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+
+      Streamer.get_topic_and_add_socket("user", user, oauth_token)
+      {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+      create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+      stream = "user:#{user.id}"
+      assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
+      refute Streamer.filtered_by_user?(user, edited)
+    end
+
+    test "it streams own edits in the 'user' stream", %{user: user, token: oauth_token} do
+      {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
+
+      Streamer.get_topic_and_add_socket("user", user, oauth_token)
+      {:ok, edited} = CommonAPI.update(user, activity, %{status: "mew mew"})
+      create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+      stream = "user:#{user.id}"
+      assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
+      refute Streamer.filtered_by_user?(user, edited)
+    end
   end
 
   describe "public streams" do
@@ -384,7 +420,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("public", user, oauth_token)
 
       {:ok, activity} = CommonAPI.post(other_user, %{status: "Test"})
-      assert_receive {:render_with_user, _, _, ^activity}
+      assert_receive {:render_with_user, _, _, ^activity, "public"}
       refute Streamer.filtered_by_user?(other_user, activity)
     end
 
@@ -416,6 +452,54 @@ defmodule Pleroma.Web.StreamerTest do
       assert_receive {:text, event}
       assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
     end
+
+    test "it streams edits in the 'public' stream" do
+      sender = insert(:user)
+
+      Streamer.get_topic_and_add_socket("public", nil, nil)
+      {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+      assert_receive {:text, _}
+
+      {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+
+      edited = Pleroma.Activity.normalize(edited)
+
+      %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+
+      assert_receive {:text, event}
+      assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+      assert %{"id" => ^activity_id} = Jason.decode!(payload)
+      refute Streamer.filtered_by_user?(sender, edited)
+    end
+
+    test "it streams multiple edits in the 'public' stream correctly" do
+      sender = insert(:user)
+
+      Streamer.get_topic_and_add_socket("public", nil, nil)
+      {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+      assert_receive {:text, _}
+
+      {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+
+      edited = Pleroma.Activity.normalize(edited)
+
+      %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+
+      assert_receive {:text, event}
+      assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+      assert %{"id" => ^activity_id} = Jason.decode!(payload)
+      refute Streamer.filtered_by_user?(sender, edited)
+
+      {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew 2"})
+
+      edited = Pleroma.Activity.normalize(edited)
+
+      %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+      assert_receive {:text, event}
+      assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+      assert %{"id" => ^activity_id, "content" => "mew mew 2"} = Jason.decode!(payload)
+      refute Streamer.filtered_by_user?(sender, edited)
+    end
   end
 
   describe "thread_containment/2" do
@@ -436,7 +520,7 @@ defmodule Pleroma.Web.StreamerTest do
 
       Streamer.get_topic_and_add_socket("public", user, oauth_token)
       Streamer.stream("public", activity)
-      assert_receive {:render_with_user, _, _, ^activity}
+      assert_receive {:render_with_user, _, _, ^activity, "public"}
       assert Streamer.filtered_by_user?(user, activity)
     end
 
@@ -458,7 +542,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("public", user, oauth_token)
       Streamer.stream("public", activity)
 
-      assert_receive {:render_with_user, _, _, ^activity}
+      assert_receive {:render_with_user, _, _, ^activity, "public"}
       refute Streamer.filtered_by_user?(user, activity)
     end
 
@@ -481,7 +565,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("public", user, oauth_token)
       Streamer.stream("public", activity)
 
-      assert_receive {:render_with_user, _, _, ^activity}
+      assert_receive {:render_with_user, _, _, ^activity, "public"}
       refute Streamer.filtered_by_user?(user, activity)
     end
   end
@@ -495,7 +579,7 @@ defmodule Pleroma.Web.StreamerTest do
 
       Streamer.get_topic_and_add_socket("public", user, oauth_token)
       {:ok, activity} = CommonAPI.post(blocked_user, %{status: "Test"})
-      assert_receive {:render_with_user, _, _, ^activity}
+      assert_receive {:render_with_user, _, _, ^activity, "public"}
       assert Streamer.filtered_by_user?(user, activity)
     end
 
@@ -512,17 +596,17 @@ defmodule Pleroma.Web.StreamerTest do
 
       {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"})
 
-      assert_receive {:render_with_user, _, _, ^activity_one}
+      assert_receive {:render_with_user, _, _, ^activity_one, "public"}
       assert Streamer.filtered_by_user?(blocker, activity_one)
 
       {:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
 
-      assert_receive {:render_with_user, _, _, ^activity_two}
+      assert_receive {:render_with_user, _, _, ^activity_two, "public"}
       assert Streamer.filtered_by_user?(blocker, activity_two)
 
       {:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"})
 
-      assert_receive {:render_with_user, _, _, ^activity_three}
+      assert_receive {:render_with_user, _, _, ^activity_three, "public"}
       assert Streamer.filtered_by_user?(blocker, activity_three)
     end
   end
@@ -583,7 +667,8 @@ defmodule Pleroma.Web.StreamerTest do
           visibility: "private"
         })
 
-      assert_receive {:render_with_user, _, _, ^activity}
+      stream_name = "list:#{list.id}"
+      assert_receive {:render_with_user, _, _, ^activity, ^stream_name}
       refute Streamer.filtered_by_user?(user_a, activity)
     end
   end
@@ -601,7 +686,8 @@ defmodule Pleroma.Web.StreamerTest do
 
       Streamer.get_topic_and_add_socket("user", user1, user1_token)
       {:ok, announce_activity} = CommonAPI.repeat(create_activity.id, user2)
-      assert_receive {:render_with_user, _, _, ^announce_activity}
+      stream_name = "user:#{user1.id}"
+      assert_receive {:render_with_user, _, _, ^announce_activity, ^stream_name}
       assert Streamer.filtered_by_user?(user1, announce_activity)
     end
 
@@ -617,7 +703,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("user", user1, user1_token)
       {:ok, _announce_activity} = CommonAPI.repeat(create_activity.id, user2)
 
-      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert_receive {:render_with_user, _, "notification.json", notif, "user"}
       assert Streamer.filtered_by_user?(user1, notif)
     end
 
@@ -633,7 +719,7 @@ defmodule Pleroma.Web.StreamerTest do
       Streamer.get_topic_and_add_socket("user", user1, user1_token)
       {:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
 
-      assert_receive {:render_with_user, _, "notification.json", notif}
+      assert_receive {:render_with_user, _, "notification.json", notif, "user"}
       refute Streamer.filtered_by_user?(user1, notif)
     end
   end
@@ -648,7 +734,8 @@ defmodule Pleroma.Web.StreamerTest do
       {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
       {:ok, _} = CommonAPI.add_mute(user2, activity)
 
-      assert_receive {:render_with_user, _, _, ^activity}
+      stream_name = "user:#{user2.id}"
+      assert_receive {:render_with_user, _, _, ^activity, ^stream_name}
       assert Streamer.filtered_by_user?(user2, activity)
     end
   end
@@ -690,7 +777,8 @@ defmodule Pleroma.Web.StreamerTest do
         })
 
       create_activity_id = create_activity.id
-      assert_receive {:render_with_user, _, _, ^create_activity}
+      stream_name = "direct:#{user.id}"
+      assert_receive {:render_with_user, _, _, ^create_activity, ^stream_name}
       assert_receive {:text, received_conversation1}
       assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
 
@@ -725,8 +813,9 @@ defmodule Pleroma.Web.StreamerTest do
           visibility: "direct"
         })
 
-      assert_receive {:render_with_user, _, _, ^create_activity}
-      assert_receive {:render_with_user, _, _, ^create_activity2}
+      stream_name = "direct:#{user.id}"
+      assert_receive {:render_with_user, _, _, ^create_activity, ^stream_name}
+      assert_receive {:render_with_user, _, _, ^create_activity2, ^stream_name}
       assert_receive {:text, received_conversation1}
       assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
       assert_receive {:text, received_conversation1}
@@ -746,4 +835,105 @@ defmodule Pleroma.Web.StreamerTest do
       assert last_status["id"] == to_string(create_activity.id)
     end
   end
+
+  describe "stop streaming if token got revoked" do
+    setup do
+      child_proc = fn start, finalize ->
+        fn ->
+          start.()
+
+          receive do
+            {StreamerTest, :ready} ->
+              assert_receive {:render_with_user, _, "update.json", _, _}
+
+              receive do
+                {StreamerTest, :revoked} -> finalize.()
+              end
+          end
+        end
+      end
+
+      starter = fn user, token ->
+        fn -> Streamer.get_topic_and_add_socket("user", user, token) end
+      end
+
+      hit = fn -> assert_receive :close end
+      miss = fn -> refute_receive :close end
+
+      send_all = fn tasks, thing -> Enum.each(tasks, &send(&1.pid, thing)) end
+
+      %{
+        child_proc: child_proc,
+        starter: starter,
+        hit: hit,
+        miss: miss,
+        send_all: send_all
+      }
+    end
+
+    test "do not revoke other tokens", %{
+      child_proc: child_proc,
+      starter: starter,
+      hit: hit,
+      miss: miss,
+      send_all: send_all
+    } do
+      %{user: user, token: token} = oauth_access(["read"])
+      %{token: token2} = oauth_access(["read"], user: user)
+      %{user: user2, token: user2_token} = oauth_access(["read"])
+
+      post_user = insert(:user)
+      CommonAPI.follow(user, post_user)
+      CommonAPI.follow(user2, post_user)
+
+      tasks = [
+        Task.async(child_proc.(starter.(user, token), hit)),
+        Task.async(child_proc.(starter.(user, token2), miss)),
+        Task.async(child_proc.(starter.(user2, user2_token), miss))
+      ]
+
+      {:ok, _} =
+        CommonAPI.post(post_user, %{
+          status: "hi"
+        })
+
+      send_all.(tasks, {StreamerTest, :ready})
+
+      Pleroma.Web.OAuth.Token.Strategy.Revoke.revoke(token)
+
+      send_all.(tasks, {StreamerTest, :revoked})
+
+      Enum.each(tasks, &Task.await/1)
+    end
+
+    test "revoke all streams for this token", %{
+      child_proc: child_proc,
+      starter: starter,
+      hit: hit,
+      send_all: send_all
+    } do
+      %{user: user, token: token} = oauth_access(["read"])
+
+      post_user = insert(:user)
+      CommonAPI.follow(user, post_user)
+
+      tasks = [
+        Task.async(child_proc.(starter.(user, token), hit)),
+        Task.async(child_proc.(starter.(user, token), hit))
+      ]
+
+      {:ok, _} =
+        CommonAPI.post(post_user, %{
+          status: "hi"
+        })
+
+      send_all.(tasks, {StreamerTest, :ready})
+
+      Pleroma.Web.OAuth.Token.Strategy.Revoke.revoke(token)
+
+      send_all.(tasks, {StreamerTest, :revoked})
+
+      Enum.each(tasks, &Task.await/1)
+    end
+  end
 end
index fb7da93f8fd6c0ab28986c88ee252670e5f7e342..d669cd0fe9663c82269e6b418c51ee3f553c7d04 100644 (file)
@@ -233,6 +233,102 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do
     end
   end
 
+  describe "POST /main/ostatus - remote_subscribe/2 - with statuses" do
+    setup do: clear_config([:instance, :federating], true)
+
+    test "renders subscribe form", %{conn: conn} do
+      user = insert(:user)
+      status = insert(:note_activity, %{user: user})
+      status_id = status.id
+
+      assert is_binary(status_id)
+
+      response =
+        conn
+        |> post("/main/ostatus", %{"status_id" => status_id, "profile" => ""})
+        |> response(:ok)
+
+      refute response =~ "Could not find status"
+      assert response =~ "Interacting with"
+    end
+
+    test "renders subscribe form with error when status not found", %{conn: conn} do
+      response =
+        conn
+        |> post("/main/ostatus", %{"status_id" => "somerandomid", "profile" => ""})
+        |> response(:ok)
+
+      assert response =~ "Could not find status"
+      refute response =~ "Interacting with"
+    end
+
+    test "it redirect to webfinger url", %{conn: conn} do
+      user = insert(:user)
+      status = insert(:note_activity, %{user: user})
+      status_id = status.id
+      status_ap_id = status.data["object"]
+
+      assert is_binary(status_id)
+      assert is_binary(status_ap_id)
+
+      user2 = insert(:user, ap_id: "shp@social.heldscal.la")
+
+      conn =
+        conn
+        |> post("/main/ostatus", %{
+          "status" => %{"status_id" => status_id, "profile" => user2.ap_id}
+        })
+
+      assert redirected_to(conn) ==
+               "https://social.heldscal.la/main/ostatussub?profile=#{status_ap_id}"
+    end
+
+    test "it renders form with error when status not found", %{conn: conn} do
+      user2 = insert(:user, ap_id: "shp@social.heldscal.la")
+
+      response =
+        conn
+        |> post("/main/ostatus", %{
+          "status" => %{"status_id" => "somerandomid", "profile" => user2.ap_id}
+        })
+        |> response(:ok)
+
+      assert response =~ "Something went wrong."
+    end
+  end
+
+  describe "GET /main/ostatus - show_subscribe_form/2" do
+    setup do: clear_config([:instance, :federating], true)
+
+    test "it works with users", %{conn: conn} do
+      user = insert(:user)
+
+      response =
+        conn
+        |> get("/main/ostatus", %{"nickname" => user.nickname})
+        |> response(:ok)
+
+      refute response =~ "Could not find user"
+      assert response =~ "Remotely follow #{user.nickname}"
+    end
+
+    test "it works with statuses", %{conn: conn} do
+      user = insert(:user)
+      status = insert(:note_activity, %{user: user})
+      status_id = status.id
+
+      assert is_binary(status_id)
+
+      response =
+        conn
+        |> get("/main/ostatus", %{"status_id" => status_id})
+        |> response(:ok)
+
+      refute response =~ "Could not find status"
+      assert response =~ "Interacting with"
+    end
+  end
+
   test "it returns new captcha", %{conn: conn} do
     with_mock Pleroma.Captcha,
       new: fn -> "test_captcha" end do
index 64d98366377a931643b18fa40102acca3fb3768a..6695886dc4f237f7ffceef53c2f476679bd61d0a 100644 (file)
@@ -111,6 +111,18 @@ defmodule Pleroma.Factory do
     }
   end
 
+  def attachment_factory(attrs \\ %{}) do
+    user = attrs[:user] || insert(:user)
+
+    data =
+      attachment_data(user.ap_id, nil)
+      |> Map.put("id", Pleroma.Web.ActivityPub.Utils.generate_object_id())
+
+    %Pleroma.Object{
+      data: merge_attributes(data, Map.get(attrs, :data, %{}))
+    }
+  end
+
   def attachment_note_factory(attrs \\ %{}) do
     user = attrs[:user] || insert(:user)
     {length, attrs} = Map.pop(attrs, :length, 1)
index 476e0ce04537f986ba3e46cd25f8a48e29a2b3df..ab44c489b791d29da2a8057cc3063098d66bd7c1 100644 (file)
@@ -407,6 +407,15 @@ defmodule HttpRequestMock do
      }}
   end
 
+  def get(
+        "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies?min_id=99512778738411824&page=true",
+        _,
+        _,
+        _
+      ) do
+    {:ok, %Tesla.Env{status: 404, body: ""}}
+  end
+
   def get("http://mastodon.example.org/users/relay", _, _, [
         {"accept", "application/activity+json"}
       ]) do
index 34b955474344d412e1686720d87f2ff1faba4208..70d331999fd43904d5fae27fcbca90feff1c3aa3 100644 (file)
@@ -5,18 +5,17 @@
 defmodule Pleroma.Integration.WebsocketClient do
   # https://github.com/phoenixframework/phoenix/blob/master/test/support/websocket_client.exs
 
+  use WebSockex
+
   @doc """
   Starts the WebSocket server for given ws URL. Received Socket.Message's
   are forwarded to the sender pid
   """
   def start_link(sender, url, headers \\ []) do
-    :crypto.start()
-    :ssl.start()
-
-    :websocket_client.start_link(
-      String.to_charlist(url),
+    WebSockex.start_link(
+      url,
       __MODULE__,
-      [sender],
+      %{sender: sender},
       extra_headers: headers
     )
   end
@@ -36,27 +35,32 @@ defmodule Pleroma.Integration.WebsocketClient do
   end
 
   @doc false
-  def init([sender], _conn_state) do
-    {:ok, %{sender: sender}}
+  @impl true
+  def handle_frame(frame, state) do
+    send(state.sender, frame)
+    {:ok, state}
   end
 
-  @doc false
-  def websocket_handle(frame, _conn_state, state) do
-    send(state.sender, frame)
+  @impl true
+  def handle_disconnect(conn_status, state) do
+    send(state.sender, {:close, conn_status})
     {:ok, state}
   end
 
   @doc false
-  def websocket_info({:text, msg}, _conn_state, state) do
+  @impl true
+  def handle_info({:text, msg}, state) do
     {:reply, {:text, msg}, state}
   end
 
-  def websocket_info(:close, _conn_state, _state) do
+  @impl true
+  def handle_info(:close, _state) do
     {:close, <<>>, "done"}
   end
 
   @doc false
-  def websocket_terminate(_reason, _conn_state, _state) do
+  @impl true
+  def terminate(_reason, _state) do
     :ok
   end
 end