diff --git a/CHANGELOG.md b/CHANGELOG.md index d6dd4dd4c..4982a3191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - extended runtime module support, see config cheatsheet +- quote posting; quotes are limited to public posts ### Fixed - Updated mastoFE path, for the newer version diff --git a/config/config.exs b/config/config.exs index cfe207dcc..bac167c29 100644 --- a/config/config.exs +++ b/config/config.exs @@ -407,6 +407,8 @@ accept: [], reject: [] +config :pleroma, :mrf_inline_quote, prefix: "RE" + # threshold of 7 days config :pleroma, :mrf_object_age, threshold: 604_800, diff --git a/docs/docs/administration/CLI_tasks/user.md b/docs/docs/administration/CLI_tasks/user.md index 0d19b5622..b7a60751d 100644 --- a/docs/docs/administration/CLI_tasks/user.md +++ b/docs/docs/administration/CLI_tasks/user.md @@ -300,3 +300,28 @@ ```sh mix pleroma.user unconfirm_all ``` + +## Fix following state + +Sometimes the system can get into a situation where +it think you're already following someone and won't send a request +to the remote instance, or won't let you unfollow someone. This +bug was fixed, but in case you encounter these weird states: + +=== "OTP" + + ```sh + ./bin/pleroma_ctl user fix_follow_state localuser remoteuser@example.com + ``` + +=== "From Source" + + ```sh + mix pleroma.user fix_follow_state localuser remoteuser@example.com + ``` + +The first argument is the local user's nickname - if you are `myuser@myinstance`, this should be `myuser`. + +The second is the remote user, consisting of both nickname AND domain. + +If you are a weird follow state situation and cannot resolve it with the above, you may need to co-operate with the remote admin to clear the state their side too - they should provide the arguments *backwards*, i.e `fix_follow_state remote local`. diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index abfe778d2..01c9df53b 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -292,6 +292,12 @@ def get_in_reply_to_activity(%Activity{} = activity) do get_in_reply_to_activity_from_object(Object.normalize(activity, fetch: false)) end + def get_quoted_activity_from_object(%Object{data: %{"quoteUri" => ap_id}}) do + get_create_by_object_ap_id_with_object(ap_id) + end + + def get_quoted_activity_from_object(_), do: nil + def normalize(%Activity{data: %{"id" => ap_id}}), do: get_by_ap_id_with_object(ap_id) def normalize(%{"id" => ap_id}), do: get_by_ap_id_with_object(ap_id) def normalize(ap_id) when is_binary(ap_id), do: get_by_ap_id_with_object(ap_id) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 8c51b8d63..97ceaf08e 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -168,6 +168,7 @@ def note(%ActivityDraft{} = draft) do "tag" => Keyword.values(draft.tags) |> Enum.uniq() } |> add_in_reply_to(draft.in_reply_to) + |> add_quote(draft.quote) |> Map.merge(draft.extra) {:ok, data, []} @@ -183,6 +184,16 @@ defp add_in_reply_to(object, in_reply_to) do end end + defp add_quote(object, nil), do: object + + defp add_quote(object, quote) do + with %Object{} = quote_object <- Object.normalize(quote, fetch: false) do + Map.put(object, "quoteUri", quote_object.data["id"]) + else + _ -> object + end + end + def answer(user, object, name) do {:ok, %{ diff --git a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex new file mode 100644 index 000000000..20432410b --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex @@ -0,0 +1,71 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do + @moduledoc "Force a quote line into the message content." + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + defp build_inline_quote(prefix, url) do + "

#{prefix}: #{url}
" + end + + defp has_inline_quote?(content, quote_url) do + cond do + # Does the quote URL exist in the content? + content =~ quote_url -> true + # Does the content already have a .quote-inline span? + content =~ "" -> true + # No inline quote found + true -> false + end + end + + defp filter_object(%{"quoteUri" => quote_url} = object) do + content = object["content"] || "" + + if has_inline_quote?(content, quote_url) do + object + else + prefix = Pleroma.Config.get([:mrf_inline_quote, :prefix]) + + content = + if String.ends_with?(content, "

") do + String.trim_trailing(content, "

") <> build_inline_quote(prefix, quote_url) <> "

" + else + content <> build_inline_quote(prefix, quote_url) + end + + Map.put(object, "content", content) + end + end + + @impl true + def filter(%{"object" => %{"quoteUri" => _} = object} = activity) do + {:ok, Map.put(activity, "object", filter_object(object))} + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_inline_quote, + related_policy: "Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy", + label: "MRF Inline Quote", + description: "Force quote post URLs inline", + children: [ + %{ + key: :prefix, + type: :string, + description: "Prefix before the link", + suggestions: ["RE", "QT", "RT", "RN"] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex index 5e377c294..a0724ca55 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex @@ -156,6 +156,7 @@ defp fix(data) do |> fix_replies() |> fix_source() |> fix_misskey_content() + |> Transmogrifier.fix_quote_url() |> Transmogrifier.fix_attachments() |> Transmogrifier.fix_emoji() |> Transmogrifier.fix_content_map() diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex index 37ec860dc..1eaf572b9 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex @@ -59,6 +59,7 @@ defmacro status_object_fields do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inReplyTo, ObjectValidators.ObjectID) + field(:quoteUri, ObjectValidators.ObjectID) field(:url, ObjectValidators.Uri) field(:likes, {:array, ObjectValidators.ObjectID}, default: []) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 115dfc470..b6ee24ee6 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -598,6 +598,12 @@ def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_r def set_reply_to_uri(obj), do: obj + def set_quote_url(%{"quoteUri" => quote} = object) when is_binary(quote) do + Map.put(object, "quoteUrl", quote) + end + + def set_quote_url(obj), do: obj + @doc """ Serialized Mastodon-compatible `replies` collection containing _self-replies_. Based on Mastodon's ActivityPub::NoteSerializer#replies. @@ -652,6 +658,7 @@ def prepare_object(object) do |> prepare_attachments |> set_conversation |> set_reply_to_uri + |> set_quote_url() |> set_replies |> strip_internal_fields |> strip_internal_tags @@ -879,6 +886,43 @@ defp strip_internal_tags(%{"tag" => tags} = object) do defp strip_internal_tags(object), do: object + def fix_quote_url(object, options \\ []) + + def fix_quote_url(%{"quoteUri" => quote_url} = object, options) + when not is_nil(quote_url) do + with {:ok, quoted_object} <- get_obj_helper(quote_url, options), + %Activity{} <- Activity.get_create_by_object_ap_id(quoted_object.data["id"]) do + Map.put(object, "quoteUri", quoted_object.data["id"]) + else + e -> + Logger.warn("Couldn't fetch #{inspect(quote_url)}, error: #{inspect(e)}") + object + end + end + + # Soapbox + def fix_quote_url(%{"quoteUrl" => quote_url} = object, options) do + object + |> Map.put("quoteUri", quote_url) + |> fix_quote_url(options) + end + + # Old Fedibird (bug) + # https://github.com/fedibird/mastodon/issues/9 + def fix_quote_url(%{"quoteURL" => quote_url} = object, options) do + object + |> Map.put("quoteUri", quote_url) + |> fix_quote_url(options) + end + + def fix_quote_url(%{"_misskey_quote" => quote_url} = object, options) do + object + |> Map.put("quoteUri", quote_url) + |> fix_quote_url(options) + end + + def fix_quote_url(object, _), do: object + def perform(:user_upgrade, user) do # we pass a fake user so that the followers collection is stripped away old_follower_address = User.ap_followers(%User{nickname: user.nickname}) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index c7df676c3..a5da8b58e 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -496,6 +496,11 @@ defp create_request do type: :string, description: "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`." + }, + quote_id: %Schema{ + nullable: true, + type: :string, + description: "Will quote a given status." } }, example: %{ diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 3caab0f00..60db8ad6f 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -133,6 +133,16 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do type: :boolean, description: "Have you pinned this status? Only appears if the status is pinnable." }, + quote_id: %Schema{ + type: :string, + description: "ID of the status being quoted", + nullable: true + }, + quote: %Schema{ + allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], + nullable: true, + description: "Quoted status (if any)" + }, pleroma: %Schema{ type: :object, properties: %{ @@ -204,6 +214,33 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do } } }, + akkoma: %Schema{ + type: :object, + properties: %{ + source: %Schema{ + nullable: true, + oneOf: [ + %Schema{type: :string, example: 'plaintext content'}, + %Schema{ + type: :object, + properties: %{ + content: %Schema{ + type: :string, + description: "The source content of the status", + nullable: true + }, + mediaType: %Schema{ + type: :string, + description: "The source MIME type of the status", + example: "text/plain", + nullable: true + } + } + } + ] + } + } + }, poll: %Schema{allOf: [Poll], nullable: true, description: "The poll attached to the status"}, reblog: %Schema{ allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 8ab50cf2b..bc5e26cf7 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -319,6 +319,10 @@ def get_replied_to_visibility(activity) do end end + def get_quoted_visibility(nil), do: nil + + def get_quoted_visibility(activity), do: get_replied_to_visibility(activity) + def check_expiry_date({:ok, nil} = res), do: res def check_expiry_date({:ok, in_seconds}) do diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index ea88213fb..767b2bf0f 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -22,6 +22,8 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do attachments: [], in_reply_to: nil, in_reply_to_conversation: nil, + quote_id: nil, + quote: nil, visibility: nil, expires_at: nil, extra: nil, @@ -54,6 +56,7 @@ def create(user, params) do |> with_valid(&in_reply_to/1) |> with_valid(&in_reply_to_conversation/1) |> with_valid(&visibility/1) + |> with_valid("e_id/1) |> content() |> with_valid(&to_and_cc/1) |> with_valid(&context/1) @@ -108,6 +111,28 @@ defp in_reply_to_conversation(draft) do %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} end + defp quote_id(%{params: %{quote_id: ""}} = draft), do: draft + + defp quote_id(%{params: %{quote_id: id}} = draft) when is_binary(id) do + with {:activity, %Activity{} = quote} <- {:activity, Activity.get_by_id(id)}, + visibility <- CommonAPI.get_quoted_visibility(quote), + {:visibility, true} <- {:visibility, visibility in ["public", "unlisted"]} do + %__MODULE__{draft | quote: Activity.get_by_id(id)} + else + {:activity, _} -> + add_error(draft, dgettext("errors", "You can't quote a status that doesn't exist")) + + {:visibility, false} -> + add_error(draft, dgettext("errors", "You can only quote public or unlisted statuses")) + end + end + + defp quote_id(%{params: %{quote_id: %Activity{} = quote}} = draft) do + %__MODULE__{draft | quote: quote} + end + + defp quote_id(draft), do: draft + defp visibility(%{params: params} = draft) do case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do {visibility, "direct"} when visibility != "direct" -> diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index c0f467592..cf4ea51e0 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -329,6 +329,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} {pinned?, pinned_at} = pin_data(object, user) + quote = Activity.get_quoted_activity_from_object(object) + %{ id: to_string(activity.id), uri: object.data["id"], @@ -363,6 +365,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} application: build_application(object.data["generator"]), language: nil, emojis: build_emojis(object.data["emoji"]), + quote_id: if(quote, do: quote.id, else: nil), + quote: maybe_render_quote(quote, opts), pleroma: %{ local: activity.local, conversation_id: get_context_id(activity), @@ -604,4 +608,19 @@ defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do end defp build_image_url(_, _), do: nil + + 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 + opts = + opts + |> Map.put(:activity, quote) + |> Map.put(:do_not_recurse, true) + + render("show.json", opts) + end + end end diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index e7722cf72..d2b62ba77 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -17,6 +17,8 @@ "ostatus": "http://ostatus.org#", "schema": "http://schema.org#", "toot": "http://joinmastodon.org/ns#", + "misskey": "https://misskey-hub.net/ns#", + "fedibird": "http://fedibird.com/ns#", "value": "schema:value", "sensitive": "as:sensitive", "litepub": "http://litepub.social/ns#", @@ -26,6 +28,8 @@ "@id": "litepub:listMessage", "@type": "@id" }, + "quoteUrl": "as:quoteUrl", + "quoteUri": "fedibird:quoteUri", "oauthRegistrationEndpoint": { "@id": "litepub:oauthRegistrationEndpoint", "@type": "@id" diff --git a/test/fixtures/fedibird/quote.json b/test/fixtures/fedibird/quote.json new file mode 100644 index 000000000..a43cc31e5 --- /dev/null +++ b/test/fixtures/fedibird/quote.json @@ -0,0 +1,73 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "fedibird": "http://fedibird.com/ns#", + "quoteUri": "fedibird:quoteUri", + "expiry": "fedibird:expiry", + "references": { + "@id": "fedibird:references", + "@type": "@id" + }, + "emojiReactions": { + "@id": "fedibird:emojiReactions", + "@type": "@id" + } + } + ], + "id": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2022-07-25T11:12:26Z", + "url": "https://fedibird.com/@akkoma_ap_integration_tester/108707679228362674", + "attributedTo": "https://fedibird.com/users/akkoma_ap_integration_tester", + "to": [ + "https://fedibird.com/users/akkoma_ap_integration_tester/followers" + ], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "sensitive": false, + "atomUri": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674", + "inReplyToAtomUri": null, + "conversation": "tag:fedibird.com,2022-07-25:objectId=108707679228389900:objectType=Conversation", + "context": "https://fedibird.com/contexts/108707679228389900", + "quoteUri": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924", + "_misskey_quote": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924", + "_misskey_content": "public quote", + "content": "

public quote
QT: https://example.com/objects/24d9f2e1-32d2-4bd5-bdf2-8ea61d3fb5e8
[参照]

", + "contentMap": { + "ja": "

public quote
QT: https://example.com/objects/24d9f2e1-32d2-4bd5-bdf2-8ea61d3fb5e8
[参照]

" + }, + "attachment": [], + "tag": [], + "replies": { + "id": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/replies?only_other_accounts=true&page=true", + "partOf": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/replies", + "items": [] + } + }, + "references": { + "id": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/references", + "type": "Collection", + "first": { + "type": "CollectionPage", + "partOf": "https://fedibird.com/users/akkoma_ap_integration_tester/statuses/108707679228362674/references", + "items": [ + "https://example.com/objects/24d9f2e1-32d2-4bd5-bdf2-8ea61d3fb5e8" + ] + } + } +} diff --git a/test/fixtures/misskey/quote.json b/test/fixtures/misskey/quote.json new file mode 100644 index 000000000..487461020 --- /dev/null +++ b/test/fixtures/misskey/quote.json @@ -0,0 +1,50 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "Hashtag": "as:Hashtag", + "quoteUrl": "as:quoteUrl", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "featured": "toot:featured", + "discoverable": "toot:discoverable", + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "misskey": "https://misskey-hub.net/ns#", + "_misskey_content": "misskey:_misskey_content", + "_misskey_quote": "misskey:_misskey_quote", + "_misskey_reaction": "misskey:_misskey_reaction", + "_misskey_votes": "misskey:_misskey_votes", + "_misskey_talk": "misskey:_misskey_talk", + "isCat": "misskey:isCat", + "vcard": "http://www.w3.org/2006/vcard/ns#" + } + ], + "id": "https://misskey.io/notes/934gok3482", + "type": "Note", + "attributedTo": "https://misskey.io/users/93492q0ip0", + "summary": null, + "content": "

i quompt u

RE:
https://example.com/objects/30c543fb-a165-40dd-87fd-4e249ec5a40b

", + "_misskey_content": "i quompt u", + "source": { + "content": "i quompt u", + "mediaType": "text/x.misskeymarkdown" + }, + "_misskey_quote": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924", + "quoteUrl": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924", + "published": "2022-07-25T15:21:48.208Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://misskey.io/users/93492q0ip0/followers" + ], + "inReplyTo": null, + "attachment": [], + "sensitive": false, + "tag": [] +} diff --git a/test/fixtures/quote_post/fedibird_quote_post.json b/test/fixtures/quote_post/fedibird_quote_post.json new file mode 100644 index 000000000..ebf383356 --- /dev/null +++ b/test/fixtures/quote_post/fedibird_quote_post.json @@ -0,0 +1,52 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "expiry": "toot:expiry" + } + ], + "id": "https://fedibird.com/users/noellabo/statuses/107663670404015196", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2022-01-22T02:07:16Z", + "url": "https://fedibird.com/@noellabo/107663670404015196", + "attributedTo": "https://fedibird.com/users/noellabo", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://fedibird.com/users/noellabo/followers" + ], + "sensitive": false, + "atomUri": "https://fedibird.com/users/noellabo/statuses/107663670404015196", + "inReplyToAtomUri": null, + "conversation": "tag:fedibird.com,2022-01-22:objectId=107663670404038002:objectType=Conversation", + "context": "https://fedibird.com/contexts/107663670404038002", + "quoteURL": "https://misskey.io/notes/8vsn2izjwh", + "_misskey_quote": "https://misskey.io/notes/8vsn2izjwh", + "_misskey_content": "いつの生まれだシトリン", + "content": "

いつの生まれだシトリン
QT: https://misskey.io/notes/8vsn2izjwh

", + "contentMap": { + "ja": "

いつの生まれだシトリン
QT: https://misskey.io/notes/8vsn2izjwh

" + }, + "attachment": [], + "tag": [], + "replies": { + "id": "https://fedibird.com/users/noellabo/statuses/107663670404015196/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://fedibird.com/users/noellabo/statuses/107663670404015196/replies?only_other_accounts=true&page=true", + "partOf": "https://fedibird.com/users/noellabo/statuses/107663670404015196/replies", + "items": [] + } + } +} diff --git a/test/fixtures/quote_post/fedibird_quote_uri.json b/test/fixtures/quote_post/fedibird_quote_uri.json new file mode 100644 index 000000000..7c328fdb9 --- /dev/null +++ b/test/fixtures/quote_post/fedibird_quote_uri.json @@ -0,0 +1,54 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "fedibird": "http://fedibird.com/ns#", + "quoteUri": "fedibird:quoteUri", + "expiry": "fedibird:expiry" + } + ], + "id": "https://fedibird.com/users/noellabo/statuses/107699335988346142", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2022-01-28T09:17:30Z", + "url": "https://fedibird.com/@noellabo/107699335988346142", + "attributedTo": "https://fedibird.com/users/noellabo", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://fedibird.com/users/noellabo/followers" + ], + "sensitive": false, + "atomUri": "https://fedibird.com/users/noellabo/statuses/107699335988346142", + "inReplyToAtomUri": null, + "conversation": "tag:fedibird.com,2022-01-28:objectId=107699335988345290:objectType=Conversation", + "context": "https://fedibird.com/contexts/107699335988345290", + "quoteUri": "https://fedibird.com/users/yamako/statuses/107699333438289729", + "_misskey_quote": "https://fedibird.com/users/yamako/statuses/107699333438289729", + "_misskey_content": "美味しそう", + "content": "

美味しそう
QT: https://fedibird.com/@yamako/107699333438289729

", + "contentMap": { + "ja": "

美味しそう
QT: https://fedibird.com/@yamako/107699333438289729

" + }, + "attachment": [], + "tag": [], + "replies": { + "id": "https://fedibird.com/users/noellabo/statuses/107699335988346142/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://fedibird.com/users/noellabo/statuses/107699335988346142/replies?only_other_accounts=true&page=true", + "partOf": "https://fedibird.com/users/noellabo/statuses/107699335988346142/replies", + "items": [] + } + } +} diff --git a/test/fixtures/quote_post/misskey_quote_post.json b/test/fixtures/quote_post/misskey_quote_post.json new file mode 100644 index 000000000..59f677ca9 --- /dev/null +++ b/test/fixtures/quote_post/misskey_quote_post.json @@ -0,0 +1,46 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "Hashtag": "as:Hashtag", + "quoteUrl": "as:quoteUrl", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "featured": "toot:featured", + "discoverable": "toot:discoverable", + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "misskey": "https://misskey.io/ns#", + "_misskey_content": "misskey:_misskey_content", + "_misskey_quote": "misskey:_misskey_quote", + "_misskey_reaction": "misskey:_misskey_reaction", + "_misskey_votes": "misskey:_misskey_votes", + "_misskey_talk": "misskey:_misskey_talk", + "isCat": "misskey:isCat", + "vcard": "http://www.w3.org/2006/vcard/ns#" + } + ], + "id": "https://misskey.io/notes/8vs6ylpfez", + "type": "Note", + "attributedTo": "https://misskey.io/users/7rkrarq81i", + "summary": null, + "content": "

投稿者の設定によるね
Fanboxについても投稿者によっては過去の投稿は高額なプランに移動してることがある

RE:
https://misskey.io/notes/8vs6wxufd0

", + "_misskey_content": "投稿者の設定によるね\nFanboxについても投稿者によっては過去の投稿は高額なプランに移動してることがある", + "_misskey_quote": "https://misskey.io/notes/8vs6wxufd0", + "quoteUrl": "https://misskey.io/notes/8vs6wxufd0", + "published": "2022-01-21T16:38:30.243Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://misskey.io/users/7rkrarq81i/followers" + ], + "inReplyTo": null, + "attachment": [], + "sensitive": false, + "tag": [] +} diff --git a/test/fixtures/quoted_status.json b/test/fixtures/quoted_status.json new file mode 100644 index 000000000..5030d1a2d --- /dev/null +++ b/test/fixtures/quoted_status.json @@ -0,0 +1,38 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://example.com/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "actor": "https://example.com/users/user", + "attachment": [ + { + "mediaType": "image/png", + "name": "", + "type": "Document", + "url": "https://example.com/media/4d6097ae20200ac371f51d24eae0a94cb4b424b6aff81dcc0f7411b1a74c796f.png" + } + ], + "attributedTo": "https://example.com/users/user", + "cc": [ + "https://example.com/users/user/followers" + ], + "content": "", + "context": "https://example.com/contexts/c2c52511-977e-4168-996c-bcf006789dca", + "conversation": "https://example.com/contexts/c2c52511-977e-4168-996c-bcf006789dca", + "id": "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924", + "published": "2022-07-24T17:25:51.614495Z", + "sensitive": null, + "source": { + "content": "", + "mediaType": "text/plain" + }, + "summary": "", + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note" +} diff --git a/test/pleroma/web/activity_pub/builder_test.exs b/test/pleroma/web/activity_pub/builder_test.exs index 3fe32bce5..640caa2b6 100644 --- a/test/pleroma/web/activity_pub/builder_test.exs +++ b/test/pleroma/web/activity_pub/builder_test.exs @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.BuilderTest do test "returns note data" do user = insert(:user) note = insert(:note) + quote = insert(:note) user2 = insert(:user) user3 = insert(:user) @@ -25,7 +26,8 @@ test "returns note data" do tags: [name: "jimm"], summary: "test summary", cc: [user3.ap_id], - extra: %{"custom_tag" => "test"} + extra: %{"custom_tag" => "test"}, + quote: quote } expected = %{ @@ -39,7 +41,8 @@ test "returns note data" do "tag" => ["jimm"], "to" => [user2.ap_id], "type" => "Note", - "custom_tag" => "test" + "custom_tag" => "test", + "quoteUri" => quote.data["id"] } assert {:ok, ^expected, []} = Builder.note(draft) diff --git a/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs b/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs new file mode 100644 index 000000000..4e0910d3e --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicyTest do + alias Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy + use Pleroma.DataCase + + test "adds quote URL to post content" do + quote_url = "https://example.com/objects/1234" + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/alex", + "object" => %{ + "type" => "Note", + "content" => "

Nice post

", + "quoteUri" => quote_url + } + } + + {:ok, %{"object" => %{"content" => filtered}}} = InlineQuotePolicy.filter(activity) + + assert filtered == + "

Nice post

RE: https://example.com/objects/1234

" + end + + test "ignores Misskey quote posts" do + object = File.read!("test/fixtures/quote_post/misskey_quote_post.json") |> Jason.decode!() + + activity = %{ + "type" => "Create", + "actor" => "https://misskey.io/users/7rkrarq81i", + "object" => object + } + + {:ok, filtered} = InlineQuotePolicy.filter(activity) + assert filtered == activity + end + + test "ignores Fedibird quote posts" do + object = File.read!("test/fixtures/quote_post/fedibird_quote_post.json") |> Jason.decode!() + + # Normally the ObjectValidator will fix this before it reaches MRF + object = Map.put(object, "quoteUrl", object["quoteURL"]) + + activity = %{ + "type" => "Create", + "actor" => "https://fedibird.com/users/noellabo", + "object" => object + } + + {:ok, filtered} = InlineQuotePolicy.filter(activity) + assert filtered == activity + end +end diff --git a/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs index 8b3982916..80290a6e3 100644 --- a/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs @@ -143,5 +143,61 @@ test "a misskey MFM status with a _misskey_content field should work and be link } } = ArticleNotePageValidator.cast_and_validate(note) end + + test "a misskey quote should work", _ do + Tesla.Mock.mock(fn %{ + method: :get, + url: "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/quoted_status.json"), + headers: HttpRequestMock.activitypub_object_headers() + } + end) + + insert(:user, %{ap_id: "https://misskey.io/users/93492q0ip0"}) + insert(:user, %{ap_id: "https://example.com/users/user"}) + + note = + "test/fixtures/misskey/quote.json" + |> File.read!() + |> Jason.decode!() + + %{ + valid?: true, + changes: %{ + quoteUri: "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924" + } + } = ArticleNotePageValidator.cast_and_validate(note) + end + + test "a fedibird quote should work", _ do + Tesla.Mock.mock(fn %{ + method: :get, + url: "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/quoted_status.json"), + headers: HttpRequestMock.activitypub_object_headers() + } + end) + + insert(:user, %{ap_id: "https://fedibird.com/users/akkoma_ap_integration_tester"}) + insert(:user, %{ap_id: "https://example.com/users/user"}) + + note = + "test/fixtures/fedibird/quote.json" + |> File.read!() + |> Jason.decode!() + + %{ + valid?: true, + changes: %{ + quoteUri: "https://example.com/objects/43479e20-c0f8-4f49-bf7f-13fab8234924" + } + } = ArticleNotePageValidator.cast_and_validate(note) + end end end diff --git a/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs index 98ab9e717..90aa9398f 100644 --- a/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/filter_controller_test.exs @@ -193,10 +193,14 @@ test "with adding expires_at", %{conn: conn, user: user} do assert response["irreversible"] == true - assert response["expires_at"] == - NaiveDateTime.utc_now() - |> NaiveDateTime.add(in_seconds) - |> Pleroma.Web.CommonAPI.Utils.to_masto_date() + expected_time = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(in_seconds) + + assert NaiveDateTime.diff( + NaiveDateTime.from_iso8601!(response["expires_at"]), + expected_time + ) < 5 filter = Filter.get(response["id"], user) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index c9f3f66be..b0efddb2a 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1944,4 +1944,102 @@ test "show" do } = result end end + + describe "posting quotes" do + setup do: oauth_access(["write:statuses"]) + + test "posting a quote", %{conn: conn} do + user = insert(:user) + {:ok, quoted_status} = CommonAPI.post(user, %{status: "tell me, for whom do you fight?"}) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "Hmph, how very glib", + "quote_id" => quoted_status.id + }) + + response = json_response_and_validate_schema(conn, 200) + + assert response["quote_id"] == quoted_status.id + assert response["quote"]["id"] == quoted_status.id + assert response["quote"]["content"] == quoted_status.object.data["content"] + end + + test "posting a quote, quoting a status that isn't public", %{conn: conn} do + user = insert(:user) + + Enum.each(["private", "local", "direct"], fn visibility -> + {:ok, quoted_status} = + CommonAPI.post(user, %{ + status: "tell me, for whom do you fight?", + visibility: visibility + }) + + assert %{"error" => "You can only quote public or unlisted statuses"} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "Hmph, how very glib", + "quote_id" => quoted_status.id + }) + |> json_response_and_validate_schema(422) + end) + end + + test "posting a quote, after quote, the status gets deleted", %{conn: conn} do + user = insert(:user) + + {:ok, quoted_status} = + CommonAPI.post(user, %{status: "tell me, for whom do you fight?", visibility: "public"}) + + resp = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "I fight for eorzea!", + "quote_id" => quoted_status.id + }) + |> json_response_and_validate_schema(200) + + {:ok, _} = CommonAPI.delete(quoted_status.id, user) + + resp = + conn + |> get("/api/v1/statuses/#{resp["id"]}") + |> json_response_and_validate_schema(200) + + assert is_nil(resp["quote"]) + end + + test "posting a quote of a deleted status", %{conn: conn} do + user = insert(:user) + + {:ok, quoted_status} = + CommonAPI.post(user, %{status: "tell me, for whom do you fight?", visibility: "public"}) + + {:ok, _} = CommonAPI.delete(quoted_status.id, user) + + assert %{"error" => _} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "I fight for eorzea!", + "quote_id" => quoted_status.id + }) + |> json_response_and_validate_schema(422) + end + + test "posting a quote of a status that doesn't exist", %{conn: conn} do + assert %{"error" => "You can't quote a status that doesn't exist"} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "I fight for eorzea!", + "quote_id" => "oops" + }) + |> json_response_and_validate_schema(422) + end + end end diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index caf2594c0..9ef44caca 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -305,7 +305,9 @@ test "a note activity" do }, akkoma: %{ source: HTML.filter_tags(object_data["content"]) - } + }, + quote_id: nil, + quote: nil } assert status == expected @@ -393,6 +395,30 @@ test "a reply" do assert status.in_reply_to_id == to_string(note.id) end + test "a quote" do + note = insert(:note_activity) + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "hehe", quote_id: note.id}) + + status = StatusView.render("show.json", %{activity: activity}) + + assert status.quote_id == to_string(note.id) + + [status] = StatusView.render("index.json", %{activities: [activity], as: :activity}) + + assert status.quote_id == to_string(note.id) + end + + test "a quote that we can't resolve" do + note = insert(:note_activity, quoteUri: "oopsie") + + status = StatusView.render("show.json", %{activity: note}) + + assert is_nil(status.quote_id) + assert is_nil(status.quote) + end + test "contains mentions" do user = insert(:user) mentioned = insert(:user)