From 8ab2b19a946251f258446d22f420d401f61d22f6 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Fri, 26 Jul 2024 12:04:28 +0200 Subject: [PATCH] [feature] Federate interaction policies + Accepts; enforce policies (#3138) * [feature] Federate interaction policies + Accepts; enforce policies * use Acceptable type * fix index * remove appendIRIStrs * add GetAccept federatingdb function * lock on object IRI --- docs/federation/posts.md | 518 ++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- internal/ap/ap_test.go | 63 +++ internal/ap/extract.go | 131 +++++ internal/ap/extractpolicy_test.go | 137 +++++ internal/ap/interfaces.go | 44 ++ internal/ap/normalize.go | 101 ++++ internal/ap/properties.go | 21 + internal/ap/resolve.go | 55 +- internal/ap/serialize.go | 3 +- internal/api/activitypub/users/acceptget.go | 55 ++ internal/api/activitypub/users/user.go | 4 + .../20240716151327_interaction_policy.go | 71 +++ .../dereferencing/status_permitted.go | 262 ++++++++- internal/federation/federatingdb/accept.go | 463 +++++++++++++--- internal/federation/federatingdb/db.go | 7 + internal/federation/federatingdb/get.go | 8 + internal/gtsmodel/status.go | 1 + internal/gtsmodel/statusfave.go | 1 + internal/processing/fedi/accept.go | 84 +++ internal/processing/processor.go | 3 +- internal/processing/status/boost.go | 11 +- internal/processing/status/create.go | 11 +- internal/processing/status/fave.go | 21 +- internal/processing/workers/federate.go | 217 +++++++- internal/processing/workers/fromclientapi.go | 192 +++++++ internal/processing/workers/fromfediapi.go | 227 +++++++- internal/processing/workers/surfacenotify.go | 250 +++++++-- internal/processing/workers/util.go | 129 +++++ internal/regexes/regexes.go | 6 + internal/transport/controller.go | 32 ++ internal/transport/dereference.go | 25 +- internal/typeutils/astointernal.go | 20 +- internal/typeutils/internaltoas.go | 281 ++++++++++ internal/typeutils/internaltoas_test.go | 243 ++++++-- internal/typeutils/wrap_test.go | 25 +- internal/uris/uri.go | 14 + .../activitystreams/type_announce/gen_pkg.go | 4 + .../gen_type_activitystreams_announce.go | 42 ++ ...type_activitystreams_announce_interface.go | 5 + vendor/modules.txt | 2 +- 42 files changed, 3541 insertions(+), 254 deletions(-) create mode 100644 internal/ap/extractpolicy_test.go create mode 100644 internal/api/activitypub/users/acceptget.go create mode 100644 internal/db/bundb/migrations/20240716151327_interaction_policy.go create mode 100644 internal/processing/fedi/accept.go diff --git a/docs/federation/posts.md b/docs/federation/posts.md index ccb7d84be..1330476a9 100644 --- a/docs/federation/posts.md +++ b/docs/federation/posts.md @@ -163,6 +163,524 @@ If `contentMap` has multiple entries, we have no way of determining the intended !!! Note In all of the above cases, if the inferred language cannot be parsed as a valid BCP47 language tag, language will fall back to unknown. +## Interaction Policy + +GoToSocial uses the property `interactionPolicy` on posts in order to indicate to remote instances what sort of interactions will be (conditionally) permitted for any given post. + +!!! danger + + Interaction policy is an attempt to limit the harmful effects of unwanted replies and other interactions on a user's posts (eg., "reply guys"). + + However, it is far from being sufficient for this purpose, as there are still many "out-of-band" ways that posts can be distributed or replied to beyond a user's initial wishes or intentions. + + For example, a user might create a post with a very strict interaction policy attached to it, only to find that other server softwares do not respect that policy, and users on other instances are having discussions and replying to the post *from their instance's perspective*. The original poster's instance will automatically drop these unwanted interactions from their view, but remote instances may still show them. + + Another example: someone might see a post that specifies nobody can reply to it, but screenshot the post, post the screenshot in their own new post, and tag the original post author in as a mention. Alternatively, they might just link to the URL of the post and tag the author in as a mention. In this case, they effectively "reply" to the post by creating a new thread. + + For better or worse, GoToSocial can offer only a best-effort, partial, technological solution to what is more or less an issue of social behavior and boundaries. + +### Overview + +`interactionPolicy` is a property attached to the status-like `Object`s `Note`, `Article`, `Question`, etc, with the following format: + +```json +{ + [...], + "interactionPolicy": { + "canLike": { + "always": [ "zero_or_more_uris_that_can_always_do_this" ], + "approvalRequired": [ "zero_or_more_uris_that_require_approval_to_do_this" ] + }, + "canReply": { + "always": [ "zero_or_more_uris_that_can_always_do_this" ], + "approvalRequired": [ "zero_or_more_uris_that_require_approval_to_do_this" ] + }, + "canAnnounce": { + "always": [ "zero_or_more_uris_that_can_always_do_this" ], + "approvalRequired": [ "zero_or_more_uris_that_require_approval_to_do_this" ] + } + }, + [...] +} +``` + +In this object: + +- `canLike` indicates who can create a `Like` with the post URI as the `Object` of the `Like`. +- `canReply` indicates who can create a post with `inReplyTo` set to the URI of the post. +- `canAnnounce` indicates who can create an `Announce` with the post URI as the `Object` of the `Announce`. + +And: + +- `always` is an array of ActivityPub URIs/IDs of `Actor`s or `Collection`s of `Actor`s who do not require an `Accept` in order to distribute an interaction to their followers (more on this below). +- `approvalRequired` is an array of ActivityPub URIs/IDs of `Actor`s or `Collection`s of `Actor`s who can interact, but should wait for an `Accept` before distributing an interaction to their followers. + +Valid URI entries in `always` and `approvalRequired` include the magic ActivityStreams Public URI `https://www.w3.org/ns/activitystreams#Public`, the URIs of the post creator's `Following` and/or `Followers` collections, and individual Actor URIs. For example: + +```json +[ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.org/users/someone/followers", + "https://example.org/users/someone/following", + "https://example.org/users/someone_else", + "https://somewhere.else.example.org/users/someone_on_a_different_instance" +] +``` + +### Specifying Nobody + +!!! note + GoToSocial makes implicit assumptions about who can/can't interact, even if a policy specifies nobody. See [implicit assumptions](#implicit-assumptions). + +An empty array, or a missing or null key, indicates that nobody can do the interaction. + +For example, the following `canLike` value indicates that nobody can `Like` the post: + +```json +"canLike": { + "always": [], + "approvalRequired": [] +}, +``` + +Likewise, a `canLike` value of `null` also indicates that nobody can `Like` the post: + +```json +"canLike": null +``` + +or + +```json +"canLike": { + "always": null, + "approvalRequired": null +} +``` + +And a missing `canLike` value does the same thing: + +```json +{ + [...], + "interactionPolicy": { + "canReply": { + "always": [ "zero_or_more_uris_that_can_always_do_this" ], + "approvalRequired": [ "zero_or_more_uris_that_require_approval_to_do_this" ] + }, + "canAnnounce": { + "always": [ "zero_or_more_uris_that_can_always_do_this" ], + "approvalRequired": [ "zero_or_more_uris_that_require_approval_to_do_this" ] + } + }, + [...] +} +``` + +### Conflicting / Duplicate Values + +In cases where a user is present in a Collection URI, and is *also* targeted explicitly by URI, the **more specific value** takes precedence. + +For example: + +```json +[...], +"canReply": { + "always": [ + "https://example.org/users/someone" + ], + "approvalRequired": [ + "https://www.w3.org/ns/activitystreams#Public" + ] +}, +[...] +``` + +Here, `@someone@example.org` is present in the `always` array, and is also implicitly present in the magic ActivityStreams Public collection in the `approvalRequired` array. In this case, they can always reply, as the `always` value is more explicit. + +Another example: + +```json +[...], +"canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [ + "https://example.org/users/someone" + ] +}, +[...] +``` + +Here, `@someone@example.org` is present in the `approvalRequired` array, but is also implicitly present in the magic ActivityStreams Public collection in the `always` array. In this case everyone can reply without approval, **except** for `@someone@example.org`, who requires approval. + +In case the **exact same** URI is present in both `always` and `approvalRequired`, the **highest level of permission** takes precedence (ie., a URI in `always` takes precedence over the same URI in `approvalRequired`). + +### Implicit Assumptions + +GoToSocial makes several implicit assumptions about `interactionPolicy`s. + +**Firstly**, users [mentioned](#mentions) in, or replied to by, a post should **ALWAYS** be able to reply to that post without requiring approval, regardless of the post visiblity and the `interactionPolicy`, **UNLESS** the post that mentioned or replied to them is itself currently pending approval. + +This is to prevent a would-be harasser from mentioning someone in an abusive post, and leaving no recourse to the mentioned user to reply. + +As such, when sending out interaction policies, GoToSocial will **ALWAYS** add the URIs of mentioned users to the `canReply.always` array, unless they are already covered by the ActivityStreams magic public URI. + +Likewise, when enforcing received interaction policies, GoToSocial will **ALWAYS** behave as though the URIs of mentioned users were present in the `canReply.always` array, even if they weren't. + +**Secondly**, a user should **ALWAYS** be able to reply to their own post, like their own post, and boost their own post without requiring approval, **UNLESS** that post is itself currently pending approval. + +As such, when sending out interaction policies, GoToSocial will **ALWAYS** add the URI of the post author to the `canLike.always`, `canReply.always`, and `canAnnounce.always` arrays, unless they are already covered by the ActivityStreams magic public URI. + +Likewise, when enforcing received interaction policies, GoToSocial will **ALWAYS** behave as though the URI of the post author is present in these `always` arrays, even if it wasn't. + +### Defaults + +When the `interactionPolicy` property is not present at all on a post, GoToSocial assumes a default `interactionPolicy` for that post appropriate to the visibility level of the post, and the post author. + +For a **public** or **unlocked** post by `@someone@example.org`, the default `interactionPolicy` is: + +```json +{ + [...], + "interactionPolicy": { + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, + [...] +} +``` + +For a **followers-only** post by `@someone@example.org`, the assumed `interactionPolicy` is: + +```json +{ + [...], + "interactionPolicy": { + "canLike": { + "always": [ + "https://example.org/users/someone", + "https://example.org/users/someone/followers", + [...URIs of any mentioned users...] + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://example.org/users/someone", + "https://example.org/users/someone/followers", + [...URIs of any mentioned users...] + ], + "approvalRequired": [] + }, + "canAnnounce": { + "always": [ + "https://example.org/users/someone" + ], + "approvalRequired": [] + } + }, + [...] +} +``` + +For a **direct** post by `@someone@example.org`, the assumed `interactionPolicy` is: + +```json +{ + [...], + "interactionPolicy": { + "canLike": { + "always": [ + "https://example.org/users/someone", + [...URIs of any mentioned users...] + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://example.org/users/someone", + [...URIs of any mentioned users...] + ], + "approvalRequired": [] + }, + "canAnnounce": { + "always": [ + "https://example.org/users/someone" + ], + "approvalRequired": [] + } + }, + [...] +} +``` + +### Example 1 - Limiting scope of a conversation + +In this example, the user `@the_mighty_zork` wants to begin a conversation with the users `@booblover6969` and `@hodor`. + +To avoid the discussion being derailed by others, they want replies to their post by users other than the three participants to be permitted only if they're approved by `@the_mighty_zork`. + +Furthermore, they want to limit the boosting / `Announce`ing of their post to only their own followers, and to the three conversation participants. + +However, anyone should be able to `Like` the post by `@the_mighty_zork`. + +This can be achieved with the following `interactionPolicy`, which is attached to a post with visibility level public: + +```json +{ + [...], + "interactionPolicy": { + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://example.org/users/the_mighty_zork", + "https://example.org/users/booblover6969", + "https://example.org/users/hodor" + ], + "approvalRequired": [ + "https://www.w3.org/ns/activitystreams#Public" + ] + }, + "canAnnounce": { + "always": [ + "https://example.org/users/the_mighty_zork", + "https://example.org/users/the_mighty_zork/followers", + "https://example.org/users/booblover6969", + "https://example.org/users/hodor" + ], + "approvalRequired": [] + } + }, + [...] +} +``` + +### Example 2 - Long solo thread + +In this example, the user `@the_mighty_zork` wants to write a long solo thread. + +They don't mind if people boost and like posts in the thread, but they don't want to get any replies because they don't have the energy to moderate the discussion; they just want to vent by throwing their thoughts out there. + +This can be achieved by setting the following `interactionPolicy` on every post in the thread: + +```json +{ + [...], + "interactionPolicy": { + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://example.org/users/the_mighty_zork" + ], + "approvalRequired": [] + }, + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, + [...] +} +``` + +Here, anyone is allowed to like or boost, but nobody is permitted to reply (except `@the_mighty_zork` themself). + +### Example 3 - Completely open + +In this example, `@the_mighty_zork` wants to write a completely open post that can be replied to, boosted, or liked by anyone who can see it (ie., the default behavior for unlocked and public posts): + +```json +{ + [...], + "interactionPolicy": { + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, + [...] +} +``` + +### Requesting, Obtaining, and Validating Approval + +When a user's URI is in the `approvalRequired` array for a type of interaction, and that user wishes to obtain approval to distribute an interaction, they should do the following: + +1. Compose the interaction `Activity` (ie., `Like`, `Create` (reply), or `Announce`), as normal. +2. Address the `Activity` `to` and `cc` the expected recipients for that `Activity`, as normal. +3. `POST` the `Activity` only to the `Inbox` (or `sharedInbox`) of the author of the post being interacted with. +4. **DO NOT DISTRIBUTE THE ACTIVITY FURTHER THAN THIS AT THIS POINT**. + +At this point, the interaction can be considered as pending approval, and should not be shown in the replies or likes collections, etc., of the post interacted with. + +It may be shown to the user who sent the interaction as a sort of "interaction pending" modal, but ideally it should not be shown to other users who share an instance with that user. + +From here, one of three things may happen: + +#### Rejection + +In this scenario, the author of the post being interacted with sends back a `Reject` `Activity` with the URI/ID of the interaction `Activity` as the `Object` property. + +For example, the following json object `Reject`s the attempt of `@someone@somewhere.else.example.org` to reply to a post by `@post_author@example.org`: + +```json +{ + "actor": "https://example.org/users/post_author", + "to": "https://somewhere.else.example.org/users/someone", + "id": "https://example.org/users/post_author/activities/reject/01J0K2YXP9QCT5BE1JWQSAM3B6", + "object": "https://somewhere.else.example.org/users/someone/statuses/01J17XY2VXGMNNPH1XR7BG2524", + "type": "Reject" +} +``` + +If this happens, `@someone@somewhere.else.example.org` (and their instance) should consider the interaction as having been rejected. The instance should delete the activity from its internal storage (ie., database), or otherwise indicate that it's been rejected, and it should not distribute the `Activity` further, or retry the interaction. + +#### Nothing + +In this scenario, the author of the post being interacted with never sends back a `Reject` or an `Accept` `Activity`. In such a case, the interaction is considered "pending" in perpetuity. Instances may wish to implement some kind of cleanup feature, where sent and pending interactions that reach a certain age should be considered expired, and `Rejected` and then removed in the manner gestured towards above. + +#### Acceptance + +In this scenario, the author of the post being interacted with sends back an `Accept` `Activity` with the URI/ID of the interaction `Activity` as the `Object` property. + +For example, the following json object `Accept`s the attempt of `@someone@somewhere.else.example.org` to reply to a post by `@post_author@example.org`: + +```json +{ + "actor": "https://example.org/users/post_author", + "to": "https://somewhere.else.example.org/users/someone", + "id": "https://example.org/users/post_author/activities/reject/01J0K2YXP9QCT5BE1JWQSAM3B6", + "object": "https://somewhere.else.example.org/users/someone/statuses/01J17XY2VXGMNNPH1XR7BG2524", + "type": "Accept" +} +``` + +If this happens, `@someone@somewhere.else.example.org` (and their instance) should consider the interaction as having been approved / accepted. The instance can then feel free to distribute the interaction `Activity` to all of the recipients targed by `to`, `cc`, etc, with the additional property `approvedBy` ([see below](#approvedby)). + +### Validating presence in a Followers / Following collection + +If an `Actor` interacting with an `Object` (via `Like`, `inReplyTo`, or `Announce`) is permitted to do that interaction based on their presence in a `Followers` or `Following` collection in the `always` field of an interaction policy, then their server should *still* wait for an `Accept` to be received from the server of the target account, before distributing the interaction more widely with the `approvedBy` property set to the URI of the `Accept`. + +This is to prevent scenarios where third servers have to somehow verify the presence of the interacting `Actor` in the `Followers` or `Following` collection of the `Actor` being interacted with. It is simpler to allow the target server to do that verification, and to trust that their `Accept` implicitly agrees that the interacting `Actor` is present in the relevant collection. + +Likewise, when receiving an interaction from an `Actor` whose permission to interact matches with one of the `Following` or `Followers` collections in the `always` property, the server of the interacted-with `Actor` should ensure that they *always* send out an `Accept` as soon as possible, so that the interacting `Actor` server can send out the `Activity` with the proper proof of acceptance. + +This process should bypass the normal "pending approval" stage whereby the server of the `Actor` being interacted with notifies them of the pending interaction, and waits for them to accept or reject, since there is no point notifying an `Actor` of a pending approval that they have already explicitly agreed to. In the GoToSocial codebase in particular, this is called "preapproval". + +### `approvedBy` + +`approvedBy` is an additional property added to the `Like`, and `Announce` activities, and any `Object`s considered to be "posts" (`Note`, `Article`, etc). + +The presence of `approvedBy` signals that the author of the post targeted by the `Activity` or replied-to by the `Object` has approved/accepted the interaction, and it can now be distributed to its intended audience. + +The value of `approvedBy` should be the URI of the `Accept` `Activity` created by the author of the post being interacted with. + +For example, the following `Announce` `Activity` indicates, by the presence of `approvedBy`, that it has been `Accept`ed by `@post_author@example.org`: + +```json +{ + "actor": "https://somewhere.else.example.org/users/someone", + "to": [ + "https://somewhere.else.example.org/users/someone/followers" + ], + "cc": [ + "https://example.org/users/post_author" + ], + "id": "https://somewhere.else.example.org/users/someone/activities/announce/01J0K2YXP9QCT5BE1JWQSAM3B6", + "object": "https://example.org/users/post_author/statuses/01J17ZZFK6W82K9MJ9SYQ33Y3D", + "approvedBy": "https://example.org/users/post_author/activities/accept/01J18043HGECBDZQPT09CP6F2X", + "type": "Announce" +} +``` + +When receiving an `Activity` with an `approvedBy` value attached to it, remote instances should dereference the URI value of the field to get the `Accept` `Activity`. + +They should then validate that the `Accept` `Activity` has an `object` value equal to the `id` of the interaction `Activity` or `Object`, and an `actor` value equal to the author of the post being interacted with. + +Moreover, they should ensure that the URL host/domain of the dereferenced `Accept` is equal to the URL host/domain of the author of the post being interacted with. + +If the `Accept` cannot be dereferenced, or does not pass validity checks, the interaction should be considered invalid and dropped. + +As a consequence of this validadtion mechanism, instances should make sure that they serve a valid ActivityPub Object in response to dereferences of `Accept` URIs that pertain to an `interactionPolicy`. If they do not, they inadvertently risk restricting the ability of remote instances to distribute their posts. + +### Subsequent Replies / Scope Widening + +Each subsequent reply in a conversation will have its own interaction policy, chosen by the user who created the reply. In other words, the entire *conversation* or *thread* is not controlled by one `interactionPolicy`, but the policy can differ for each subsequent post in a thread, as set by the post author. + +Unfortunately, this means that even with `interactionPolicy` in place, the scope of a thread can inadvertently widen beyond the intention of the author of the first post in the thread. + +For instance, in [example 1](#example-1---limiting-scope-of-a-conversation) above, `@the_mighty_zork` specifies in the first post a `canReply.always` value of + +```json +[ + "https://example.org/users/the_mighty_zork", + "https://example.org/users/booblover6969", + "https://example.org/users/hodor" +] +``` + +In a subsequent reply, either accidentally or on purpose `@booblover6969` sets the `canReply.always` value to: + +```json +[ + "https://www.w3.org/ns/activitystreams#Public" +] +``` + +This widens the scope of the conversation, as now anyone can reply to `@booblover6969`'s post, and possibly also tag `@the_mighty_zork` in that reply. + +To avoid this issue, it is recommended that remote instances prevent users from being able to widen scope (exact mechanism of doing this TBD). + +It is also a good idea for instances to consider any interaction with a post- or status-like `Object` that is itself currently pending approval, as also pending approval. + +In other words, instances should mark all children interactions below a pending-approval parent as also pending approval, no matter what the interaction policy on the parent would ordinarily allow. + +This avoids situations where someone could reply to a post, then, even if their reply is pending approval, they could reply *to their own reply* and have that marked as permitted (since as author, they would normally have [implicit permission to reply](#implicit-assumptions)). + ## Polls To federate polls in and out, GoToSocial uses the widely-adopted [ActivityStreams `Question` type](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-question). This however, as first introduced and popularised by Mastodon, does slightly vary from the ActivityStreams specification. In the specification the Question type is marked as an extension of "IntransitiveActivity", an "Activity" extension that should be passed without an "Object" and all further details contained implicitly. But in implementation it is passed as an "Object", as part of "Create" or "Update" activities. diff --git a/go.mod b/go.mod index cbdc52dfc..28a231368 100644 --- a/go.mod +++ b/go.mod @@ -50,7 +50,7 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 - github.com/superseriousbusiness/activity v1.7.0-gts + github.com/superseriousbusiness/activity v1.8.0-gts github.com/superseriousbusiness/httpsig v1.2.0-SSB github.com/superseriousbusiness/oauth2/v4 v4.3.2-SSB.0.20230227143000-f4900831d6c8 github.com/tdewolff/minify/v2 v2.20.37 diff --git a/go.sum b/go.sum index 93ebb2db7..09860d9c1 100644 --- a/go.sum +++ b/go.sum @@ -505,8 +505,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/superseriousbusiness/activity v1.7.0-gts h1:DsCvzksTWptn7JUDTFIIiJ7xkh0A22VZs5KI3q67p+4= -github.com/superseriousbusiness/activity v1.7.0-gts/go.mod h1:AZw0Xb4Oju8rmaJCZ21gc5CPg47MmNgyac+Hx5jo8VM= +github.com/superseriousbusiness/activity v1.8.0-gts h1:CMSN1eZUwNfIX1DFo4YxRCzSeT4jmGoIdakt/ZuDkQM= +github.com/superseriousbusiness/activity v1.8.0-gts/go.mod h1:AZw0Xb4Oju8rmaJCZ21gc5CPg47MmNgyac+Hx5jo8VM= github.com/superseriousbusiness/httpsig v1.2.0-SSB h1:BinBGKbf2LSuVT5+MuH0XynHN9f0XVshx2CTDtkaWj0= github.com/superseriousbusiness/httpsig v1.2.0-SSB/go.mod h1:+rxfATjFaDoDIVaJOTSP0gj6UrbicaYPEptvCLC9F28= github.com/superseriousbusiness/oauth2/v4 v4.3.2-SSB.0.20230227143000-f4900831d6c8 h1:nTIhuP157oOFcscuoK1kCme1xTeGIzztSw70lX9NrDQ= diff --git a/internal/ap/ap_test.go b/internal/ap/ap_test.go index 0a9f66ca6..f982e4443 100644 --- a/internal/ap/ap_test.go +++ b/internal/ap/ap_test.go @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -103,6 +104,66 @@ func noteWithMentions1() vocab.ActivityStreamsNote { note.SetActivityStreamsContent(content) + policy := streams.NewGoToSocialInteractionPolicy() + + // Set canLike. + canLike := streams.NewGoToSocialCanLike() + + // Anyone can like. + canLikeAlwaysProp := streams.NewGoToSocialAlwaysProperty() + canLikeAlwaysProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + canLike.SetGoToSocialAlways(canLikeAlwaysProp) + + // Empty approvalRequired. + canLikeApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + canLike.SetGoToSocialApprovalRequired(canLikeApprovalRequiredProp) + + // Set canLike on the policy. + canLikeProp := streams.NewGoToSocialCanLikeProperty() + canLikeProp.AppendGoToSocialCanLike(canLike) + policy.SetGoToSocialCanLike(canLikeProp) + + // Build canReply. + canReply := streams.NewGoToSocialCanReply() + + // Anyone can reply. + canReplyAlwaysProp := streams.NewGoToSocialAlwaysProperty() + canReplyAlwaysProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + canReply.SetGoToSocialAlways(canReplyAlwaysProp) + + // Set empty approvalRequired. + canReplyApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + canReply.SetGoToSocialApprovalRequired(canReplyApprovalRequiredProp) + + // Set canReply on the policy. + canReplyProp := streams.NewGoToSocialCanReplyProperty() + canReplyProp.AppendGoToSocialCanReply(canReply) + policy.SetGoToSocialCanReply(canReplyProp) + + // Build canAnnounce. + canAnnounce := streams.NewGoToSocialCanAnnounce() + + // Only f0x and dumpsterqueer can announce. + canAnnounceAlwaysProp := streams.NewGoToSocialAlwaysProperty() + canAnnounceAlwaysProp.AppendIRI(testrig.URLMustParse("https://gts.superseriousbusiness.org/users/dumpsterqueer")) + canAnnounceAlwaysProp.AppendIRI(testrig.URLMustParse("https://gts.superseriousbusiness.org/users/f0x")) + canAnnounce.SetGoToSocialAlways(canAnnounceAlwaysProp) + + // Public requires approval to announce. + canAnnounceApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + canAnnounceApprovalRequiredProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + canAnnounce.SetGoToSocialApprovalRequired(canAnnounceApprovalRequiredProp) + + // Set canAnnounce on the policy. + canAnnounceProp := streams.NewGoToSocialCanAnnounceProperty() + canAnnounceProp.AppendGoToSocialCanAnnounce(canAnnounce) + policy.SetGoToSocialCanAnnounce(canAnnounceProp) + + // Set the policy on the note. + policyProp := streams.NewGoToSocialInteractionPolicyProperty() + policyProp.AppendGoToSocialInteractionPolicy(policy) + note.SetGoToSocialInteractionPolicy(policyProp) + return note } @@ -296,6 +357,7 @@ type APTestSuite struct { addressable3 ap.Addressable addressable4 vocab.ActivityStreamsAnnounce addressable5 ap.Addressable + testAccounts map[string]*gtsmodel.Account } func (suite *APTestSuite) jsonToType(rawJson string) (vocab.Type, map[string]interface{}) { @@ -336,4 +398,5 @@ func (suite *APTestSuite) SetupTest() { suite.addressable3 = addressable3() suite.addressable4 = addressable4() suite.addressable5 = addressable5() + suite.testAccounts = testrig.NewTestAccounts() } diff --git a/internal/ap/extract.go b/internal/ap/extract.go index e0c90c5d7..ce1e2d421 100644 --- a/internal/ap/extract.go +++ b/internal/ap/extract.go @@ -1057,6 +1057,137 @@ func ExtractVisibility(addressable Addressable, actorFollowersURI string) (gtsmo return visibility, nil } +// ExtractInteractionPolicy extracts a *gtsmodel.InteractionPolicy +// from the given Statusable created by by the given *gtsmodel.Account. +// +// Will be nil (default policy) for Statusables that have no policy +// set on them, or have a null policy. In such a case, the caller +// should assume the default policy for the status's visibility level. +func ExtractInteractionPolicy( + statusable Statusable, + owner *gtsmodel.Account, +) *gtsmodel.InteractionPolicy { + policyProp := statusable.GetGoToSocialInteractionPolicy() + if policyProp == nil || policyProp.Len() != 1 { + return nil + } + + policyPropIter := policyProp.At(0) + if !policyPropIter.IsGoToSocialInteractionPolicy() { + return nil + } + + policy := policyPropIter.Get() + if policy == nil { + return nil + } + + return >smodel.InteractionPolicy{ + CanLike: extractCanLike(policy.GetGoToSocialCanLike(), owner), + CanReply: extractCanReply(policy.GetGoToSocialCanReply(), owner), + CanAnnounce: extractCanAnnounce(policy.GetGoToSocialCanAnnounce(), owner), + } +} + +func extractCanLike( + prop vocab.GoToSocialCanLikeProperty, + owner *gtsmodel.Account, +) gtsmodel.PolicyRules { + if prop == nil || prop.Len() != 1 { + return gtsmodel.PolicyRules{} + } + + propIter := prop.At(0) + if !propIter.IsGoToSocialCanLike() { + return gtsmodel.PolicyRules{} + } + + withRules := propIter.Get() + if withRules == nil { + return gtsmodel.PolicyRules{} + } + + return gtsmodel.PolicyRules{ + Always: extractPolicyValues(withRules.GetGoToSocialAlways(), owner), + WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner), + } +} + +func extractCanReply( + prop vocab.GoToSocialCanReplyProperty, + owner *gtsmodel.Account, +) gtsmodel.PolicyRules { + if prop == nil || prop.Len() != 1 { + return gtsmodel.PolicyRules{} + } + + propIter := prop.At(0) + if !propIter.IsGoToSocialCanReply() { + return gtsmodel.PolicyRules{} + } + + withRules := propIter.Get() + if withRules == nil { + return gtsmodel.PolicyRules{} + } + + return gtsmodel.PolicyRules{ + Always: extractPolicyValues(withRules.GetGoToSocialAlways(), owner), + WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner), + } +} + +func extractCanAnnounce( + prop vocab.GoToSocialCanAnnounceProperty, + owner *gtsmodel.Account, +) gtsmodel.PolicyRules { + if prop == nil || prop.Len() != 1 { + return gtsmodel.PolicyRules{} + } + + propIter := prop.At(0) + if !propIter.IsGoToSocialCanAnnounce() { + return gtsmodel.PolicyRules{} + } + + withRules := propIter.Get() + if withRules == nil { + return gtsmodel.PolicyRules{} + } + + return gtsmodel.PolicyRules{ + Always: extractPolicyValues(withRules.GetGoToSocialAlways(), owner), + WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner), + } +} + +func extractPolicyValues[T WithIRI]( + prop Property[T], + owner *gtsmodel.Account, +) gtsmodel.PolicyValues { + iris := getIRIs(prop) + PolicyValues := make(gtsmodel.PolicyValues, 0, len(iris)) + + for _, iri := range iris { + switch iriStr := iri.String(); iriStr { + case pub.PublicActivityPubIRI: + PolicyValues = append(PolicyValues, gtsmodel.PolicyValuePublic) + case owner.FollowersURI: + PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowers) + case owner.FollowingURI: + PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowers) + case owner.URI: + PolicyValues = append(PolicyValues, gtsmodel.PolicyValueAuthor) + default: + if iri.Scheme == "http" || iri.Scheme == "https" { + PolicyValues = append(PolicyValues, gtsmodel.PolicyValue(iriStr)) + } + } + } + + return PolicyValues +} + // ExtractSensitive extracts whether or not an item should // be marked as sensitive according to its ActivityStreams // sensitive property. diff --git a/internal/ap/extractpolicy_test.go b/internal/ap/extractpolicy_test.go new file mode 100644 index 000000000..3d5e75c41 --- /dev/null +++ b/internal/ap/extractpolicy_test.go @@ -0,0 +1,137 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package ap_test + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type ExtractPolicyTestSuite struct { + APTestSuite +} + +func (suite *ExtractPolicyTestSuite) TestExtractPolicy() { + rawNote := `{ + "@context": [ + "https://gotosocial.org/ns", + "https://www.w3.org/ns/activitystreams" + ], + "content": "hey @f0x and @dumpsterqueer", + "contentMap": { + "en": "hey @f0x and @dumpsterqueer", + "fr": "bonjour @f0x et @dumpsterqueer" + }, + "interactionPolicy": { + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "http://localhost:8080/users/the_mighty_zork", + "http://localhost:8080/users/the_mighty_zork/followers", + "https://gts.superseriousbusiness.org/users/dumpsterqueer", + "https://gts.superseriousbusiness.org/users/f0x" + ], + "approvalRequired": [ + "https://www.w3.org/ns/activitystreams#Public" + ] + }, + "canAnnounce": { + "always": [ + "http://localhost:8080/users/the_mighty_zork" + ], + "approvalRequired": [ + "https://www.w3.org/ns/activitystreams#Public" + ] + } + }, + "tag": [ + { + "href": "https://gts.superseriousbusiness.org/users/dumpsterqueer", + "name": "@dumpsterqueer@superseriousbusiness.org", + "type": "Mention" + }, + { + "href": "https://gts.superseriousbusiness.org/users/f0x", + "name": "@f0x@superseriousbusiness.org", + "type": "Mention" + } + ], + "type": "Note" +}` + + statusable, err := ap.ResolveStatusable( + context.Background(), + io.NopCloser( + bytes.NewBufferString(rawNote), + ), + ) + if err != nil { + suite.FailNow(err.Error()) + } + + policy := ap.ExtractInteractionPolicy( + statusable, + // Zork didn't actually create + // this status but nevermind. + suite.testAccounts["local_account_1"], + ) + + expectedPolicy := >smodel.InteractionPolicy{ + CanLike: gtsmodel.PolicyRules{ + Always: gtsmodel.PolicyValues{ + gtsmodel.PolicyValuePublic, + }, + WithApproval: gtsmodel.PolicyValues{}, + }, + CanReply: gtsmodel.PolicyRules{ + Always: gtsmodel.PolicyValues{ + gtsmodel.PolicyValueAuthor, + gtsmodel.PolicyValueFollowers, + "https://gts.superseriousbusiness.org/users/dumpsterqueer", + "https://gts.superseriousbusiness.org/users/f0x", + }, + WithApproval: gtsmodel.PolicyValues{ + gtsmodel.PolicyValuePublic, + }, + }, + CanAnnounce: gtsmodel.PolicyRules{ + Always: gtsmodel.PolicyValues{ + gtsmodel.PolicyValueAuthor, + }, + WithApproval: gtsmodel.PolicyValues{ + gtsmodel.PolicyValuePublic, + }, + }, + } + suite.EqualValues(expectedPolicy, policy) +} + +func TestExtractPolicyTestSuite(t *testing.T) { + suite.Run(t, &ExtractPolicyTestSuite{}) +} diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go index 8f2e17c09..a721fa997 100644 --- a/internal/ap/interfaces.go +++ b/internal/ap/interfaces.go @@ -124,6 +124,24 @@ func ToPollOptionable(t vocab.Type) (PollOptionable, bool) { return note, true } +// IsAccept returns whether AS vocab type name +// is something that can be cast to Accept. +func IsAcceptable(typeName string) bool { + return typeName == ActivityAccept +} + +// ToAcceptable safely tries to cast vocab.Type as vocab.ActivityStreamsAccept. +// +// TODO: Add additional "Accept" types here, eg., "ApproveReply" from +// https://codeberg.org/fediverse/fep/src/branch/main/fep/5624/fep-5624.md +func ToAcceptable(t vocab.Type) (vocab.ActivityStreamsAccept, bool) { + acceptable, ok := t.(vocab.ActivityStreamsAccept) + if !ok || !IsAcceptable(t.GetTypeName()) { + return nil, false + } + return acceptable, true +} + // Activityable represents the minimum activitypub interface for representing an 'activity'. // (see: IsActivityable() for types implementing this, though you MUST make sure to check // the typeName as this bare interface may be implementable by non-Activityable types). @@ -188,6 +206,8 @@ type Statusable interface { WithAttachment WithTag WithReplies + WithInteractionPolicy + WithApprovedBy } // Pollable represents the minimum activitypub interface for representing a 'poll' (it's a subset of a status). @@ -217,6 +237,12 @@ type PollOptionable interface { WithAttributedTo } +// Acceptable represents the minimum activitypub +// interface for representing an Accept. +type Acceptable interface { + Activityable +} + // Attachmentable represents the minimum activitypub interface for representing a 'mediaAttachment'. (see: IsAttachmentable). // This interface is fulfilled by: Audio, Document, Image, Video type Attachmentable interface { @@ -657,3 +683,21 @@ type WithVotersCount interface { GetTootVotersCount() vocab.TootVotersCountProperty SetTootVotersCount(vocab.TootVotersCountProperty) } + +// WithReplies represents an object with GoToSocialInteractionPolicy. +type WithInteractionPolicy interface { + GetGoToSocialInteractionPolicy() vocab.GoToSocialInteractionPolicyProperty + SetGoToSocialInteractionPolicy(vocab.GoToSocialInteractionPolicyProperty) +} + +// WithPolicyRules represents an activity with always and approvalRequired properties. +type WithPolicyRules interface { + GetGoToSocialAlways() vocab.GoToSocialAlwaysProperty + GetGoToSocialApprovalRequired() vocab.GoToSocialApprovalRequiredProperty +} + +// WithApprovedBy represents a Statusable with the approvedBy property. +type WithApprovedBy interface { + GetGoToSocialApprovedBy() vocab.GoToSocialApprovedByProperty + SetGoToSocialApprovedBy(vocab.GoToSocialApprovedByProperty) +} diff --git a/internal/ap/normalize.go b/internal/ap/normalize.go index bef6d93b0..30d8515a5 100644 --- a/internal/ap/normalize.go +++ b/internal/ap/normalize.go @@ -575,6 +575,107 @@ func NormalizeOutgoingContentProp(item WithContent, rawJSON map[string]interface } } +// NormalizeOutgoingInteractionPolicyProp replaces single-entry interactionPolicy values +// with single-entry arrays, for better compatibility with other AP implementations. +// +// Ie: +// +// "interactionPolicy": { +// "canAnnounce": { +// "always": "https://www.w3.org/ns/activitystreams#Public", +// "approvalRequired": [] +// }, +// "canLike": { +// "always": "https://www.w3.org/ns/activitystreams#Public", +// "approvalRequired": [] +// }, +// "canReply": { +// "always": "https://www.w3.org/ns/activitystreams#Public", +// "approvalRequired": [] +// } +// } +// +// becomes: +// +// "interactionPolicy": { +// "canAnnounce": { +// "always": [ +// "https://www.w3.org/ns/activitystreams#Public" +// ], +// "approvalRequired": [] +// }, +// "canLike": { +// "always": [ +// "https://www.w3.org/ns/activitystreams#Public" +// ], +// "approvalRequired": [] +// }, +// "canReply": { +// "always": [ +// "https://www.w3.org/ns/activitystreams#Public" +// ], +// "approvalRequired": [] +// } +// } +// +// Noop for items with no attachments, or with attachments that are already a slice. +func NormalizeOutgoingInteractionPolicyProp(item WithInteractionPolicy, rawJSON map[string]interface{}) { + policy, ok := rawJSON["interactionPolicy"] + if !ok { + // No 'interactionPolicy', + // nothing to change. + return + } + + policyMap, ok := policy.(map[string]interface{}) + if !ok { + // Malformed 'interactionPolicy', + // nothing to change. + return + } + + for _, rulesKey := range []string{ + "canLike", + "canReply", + "canAnnounce", + } { + // Either "canAnnounce", + // "canLike", or "canApprove" + rulesVal, ok := policyMap[rulesKey] + if !ok { + // Not set. + return + } + + rulesValMap, ok := rulesVal.(map[string]interface{}) + if !ok { + // Malformed or not + // present skip. + return + } + + for _, PolicyValuesKey := range []string{ + "always", + "approvalRequired", + } { + PolicyValuesVal, ok := rulesValMap[PolicyValuesKey] + if !ok { + // Not set. + continue + } + + if _, ok := PolicyValuesVal.([]interface{}); ok { + // Already slice, + // nothing to change. + continue + } + + // Coerce single-object to slice. + rulesValMap[PolicyValuesKey] = []interface{}{PolicyValuesVal} + } + } +} + // NormalizeOutgoingObjectProp normalizes each Object entry in the rawJSON of the given // item by calling custom serialization / normalization functions on them in turn. // diff --git a/internal/ap/properties.go b/internal/ap/properties.go index 1bd8c303e..38e58ebc0 100644 --- a/internal/ap/properties.go +++ b/internal/ap/properties.go @@ -520,6 +520,27 @@ func SetManuallyApprovesFollowers(with WithManuallyApprovesFollowers, manuallyAp mafProp.Set(manuallyApprovesFollowers) } +// GetApprovedBy returns the URL contained in +// the ApprovedBy property of 'with', if set. +func GetApprovedBy(with WithApprovedBy) *url.URL { + mafProp := with.GetGoToSocialApprovedBy() + if mafProp == nil || !mafProp.IsIRI() { + return nil + } + return mafProp.Get() +} + +// SetApprovedBy sets the given url +// on the ApprovedBy property of 'with'. +func SetApprovedBy(with WithApprovedBy, approvedBy *url.URL) { + abProp := with.GetGoToSocialApprovedBy() + if abProp == nil { + abProp = streams.NewGoToSocialApprovedByProperty() + with.SetGoToSocialApprovedBy(abProp) + } + abProp.Set(approvedBy) +} + // extractIRIs extracts just the AP IRIs from an iterable // property that may contain types (with IRIs) or just IRIs. // diff --git a/internal/ap/resolve.go b/internal/ap/resolve.go index b2e866b6f..76a8809c3 100644 --- a/internal/ap/resolve.go +++ b/internal/ap/resolve.go @@ -37,6 +37,8 @@ func ResolveIncomingActivity(r *http.Request) (pub.Activity, bool, gtserror.With // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -79,9 +81,6 @@ func ResolveIncomingActivity(r *http.Request) (pub.Activity, bool, gtserror.With // (see: https://github.com/superseriousbusiness/gotosocial/issues/1661) NormalizeIncomingActivity(activity, raw) - // Release. - putMap(raw) - return activity, true, nil } @@ -93,6 +92,8 @@ func ResolveStatusable(ctx context.Context, body io.ReadCloser) (Statusable, err // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -121,9 +122,6 @@ func ResolveStatusable(ctx context.Context, body io.ReadCloser) (Statusable, err NormalizeIncomingSummary(statusable, raw) NormalizeIncomingName(statusable, raw) - // Release. - putMap(raw) - return statusable, nil } @@ -135,6 +133,8 @@ func ResolveAccountable(ctx context.Context, body io.ReadCloser) (Accountable, e // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -153,9 +153,6 @@ func ResolveAccountable(ctx context.Context, body io.ReadCloser) (Accountable, e NormalizeIncomingSummary(accountable, raw) - // Release. - putMap(raw) - return accountable, nil } @@ -165,6 +162,8 @@ func ResolveCollection(ctx context.Context, body io.ReadCloser) (CollectionItera // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -174,9 +173,6 @@ func ResolveCollection(ctx context.Context, body io.ReadCloser) (CollectionItera return nil, gtserror.SetWrongType(err) } - // Release. - putMap(raw) - // Cast as as Collection-like. return ToCollectionIterator(t) } @@ -187,6 +183,8 @@ func ResolveCollectionPage(ctx context.Context, body io.ReadCloser) (CollectionP // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -196,13 +194,40 @@ func ResolveCollectionPage(ctx context.Context, body io.ReadCloser) (CollectionP return nil, gtserror.SetWrongType(err) } - // Release. - putMap(raw) - // Cast as as CollectionPage-like. return ToCollectionPageIterator(t) } +// ResolveAcceptable tries to resolve the given reader +// into an ActivityStreams Acceptable representation. +func ResolveAcceptable( + ctx context.Context, + body io.ReadCloser, +) (Acceptable, error) { + // Get "raw" map + // destination. + raw := getMap() + // Release. + defer putMap(raw) + + // Decode data as JSON into 'raw' map + // and get the resolved AS vocab.Type. + // (this handles close of given body). + t, err := decodeType(ctx, body, raw) + if err != nil { + return nil, gtserror.SetWrongType(err) + } + + // Attempt to cast as acceptable. + acceptable, ok := ToAcceptable(t) + if !ok { + err := gtserror.Newf("cannot resolve vocab type %T as acceptable", t) + return nil, gtserror.SetWrongType(err) + } + + return acceptable, nil +} + // emptydest is an empty JSON decode // destination useful for "noop" decodes // to check underlying reader is empty. diff --git a/internal/ap/serialize.go b/internal/ap/serialize.go index b13ebb340..3e5a92d79 100644 --- a/internal/ap/serialize.go +++ b/internal/ap/serialize.go @@ -37,7 +37,7 @@ import ( // - OrderedCollection: 'orderedItems' property will always be made into an array. // - OrderedCollectionPage: 'orderedItems' property will always be made into an array. // - Any Accountable type: 'attachment' property will always be made into an array. -// - Any Statusable type: 'attachment' property will always be made into an array; 'content' and 'contentMap' will be normalized. +// - Any Statusable type: 'attachment' property will always be made into an array; 'content', 'contentMap', and 'interactionPolicy' will be normalized. // - Any Activityable type: any 'object's set on an activity will be custom serialized as above. func Serialize(t vocab.Type) (m map[string]interface{}, e error) { switch tn := t.GetTypeName(); { @@ -153,6 +153,7 @@ func serializeStatusable(t vocab.Type, includeContext bool) (map[string]interfac NormalizeOutgoingAttachmentProp(statusable, data) NormalizeOutgoingContentProp(statusable, data) + NormalizeOutgoingInteractionPolicyProp(statusable, data) return data, nil } diff --git a/internal/api/activitypub/users/acceptget.go b/internal/api/activitypub/users/acceptget.go new file mode 100644 index 000000000..c2b438330 --- /dev/null +++ b/internal/api/activitypub/users/acceptget.go @@ -0,0 +1,55 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package users + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// AcceptGETHandler serves an interactionApproval as an ActivityStreams Accept. +func (m *Module) AcceptGETHandler(c *gin.Context) { + username, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + acceptID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + contentType, err := apiutil.NegotiateAccept(c, apiutil.ActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Fedi().AcceptGet(c.Request.Context(), username, acceptID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSONType(c, http.StatusOK, contentType, resp) +} diff --git a/internal/api/activitypub/users/user.go b/internal/api/activitypub/users/user.go index 5e3d5d187..5122e610e 100644 --- a/internal/api/activitypub/users/user.go +++ b/internal/api/activitypub/users/user.go @@ -21,6 +21,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/uris" ) @@ -55,6 +56,8 @@ const ( StatusPath = BasePath + "/" + uris.StatusesPath + "/:" + StatusIDKey // StatusRepliesPath is for serving the replies collection of a status. StatusRepliesPath = StatusPath + "/replies" + // AcceptPath is for serving accepts of a status. + AcceptPath = BasePath + "/" + uris.AcceptsPath + "/:" + apiutil.IDKey ) type Module struct { @@ -76,4 +79,5 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, StatusPath, m.StatusGETHandler) attachHandler(http.MethodGet, StatusRepliesPath, m.StatusRepliesGETHandler) attachHandler(http.MethodGet, OutboxPath, m.OutboxGETHandler) + attachHandler(http.MethodGet, AcceptPath, m.AcceptGETHandler) } diff --git a/internal/db/bundb/migrations/20240716151327_interaction_policy.go b/internal/db/bundb/migrations/20240716151327_interaction_policy.go new file mode 100644 index 000000000..fb0d1d752 --- /dev/null +++ b/internal/db/bundb/migrations/20240716151327_interaction_policy.go @@ -0,0 +1,71 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migrations + +import ( + "context" + + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx. + NewCreateTable(). + Model(>smodel.InteractionApproval{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateIndex(). + Table("interaction_approvals"). + Index("interaction_approvals_account_id_idx"). + Column("account_id"). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateIndex(). + Table("interaction_approvals"). + Index("interaction_approvals_interacting_account_id_idx"). + Column("interacting_account_id"). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/federation/dereferencing/status_permitted.go b/internal/federation/dereferencing/status_permitted.go index 5c16f9f15..97cd61e93 100644 --- a/internal/federation/dereferencing/status_permitted.go +++ b/internal/federation/dereferencing/status_permitted.go @@ -19,10 +19,13 @@ package dereferencing import ( "context" + "net/url" + "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // isPermittedStatus returns whether the given status @@ -147,12 +150,67 @@ func (d *Dereferencer) isPermittedReply( return onFalse() } - // TODO in next PR: check conditional / - // with approval and deref Accept. - if !replyable.Permitted() { + if replyable.Permitted() && + !replyable.MatchedOnCollection() { + // Replier is permitted to do this + // interaction, and didn't match on + // a collection so we don't need to + // do further checking. + return true, nil + } + + // Replier is permitted to do this + // interaction pending approval, or + // permitted but matched on a collection. + // + // Check if we can dereference + // an Accept that grants approval. + + if status.ApprovedByURI == "" { + // Status doesn't claim to be approved. + // + // For replies to local statuses that's + // fine, we can put it in the DB pending + // approval, and continue processing it. + // + // If permission was granted based on a match + // with a followers or following collection, + // we can mark it as PreApproved so the processor + // sends an accept out for it immediately. + // + // For replies to remote statuses, though + // we should be polite and just drop it. + if inReplyTo.IsLocal() { + status.PendingApproval = util.Ptr(true) + status.PreApproved = replyable.MatchedOnCollection() + return true, nil + } + return onFalse() } + // Status claims to be approved, check + // this by dereferencing the Accept and + // inspecting the return value. + if err := d.validateApprovedBy( + ctx, + requestUser, + status.ApprovedByURI, + status.URI, + inReplyTo.AccountURI, + ); err != nil { + // Error dereferencing means we couldn't + // get the Accept right now or it wasn't + // valid, so we shouldn't store this status. + // + // Do log the error though as it may be + // interesting for admins to see. + log.Info(ctx, "rejecting reply with undereferenceable ApprovedByURI: %v", err) + return onFalse() + } + + // Status has been approved. + status.PendingApproval = util.Ptr(false) return true, nil } @@ -206,11 +264,203 @@ func (d *Dereferencer) isPermittedBoost( return onFalse() } - // TODO in next PR: check conditional / - // with approval and deref Accept. - if !boostable.Permitted() { + if boostable.Permitted() && + !boostable.MatchedOnCollection() { + // Booster is permitted to do this + // interaction, and didn't match on + // a collection so we don't need to + // do further checking. + return true, nil + } + + // Booster is permitted to do this + // interaction pending approval, or + // permitted but matched on a collection. + // + // Check if we can dereference + // an Accept that grants approval. + + if status.ApprovedByURI == "" { + // Status doesn't claim to be approved. + // + // For boosts of local statuses that's + // fine, we can put it in the DB pending + // approval, and continue processing it. + // + // If permission was granted based on a match + // with a followers or following collection, + // we can mark it as PreApproved so the processor + // sends an accept out for it immediately. + // + // For boosts of remote statuses, though + // we should be polite and just drop it. + if boostOf.IsLocal() { + status.PendingApproval = util.Ptr(true) + status.PreApproved = boostable.MatchedOnCollection() + return true, nil + } + return onFalse() } + // Boost claims to be approved, check + // this by dereferencing the Accept and + // inspecting the return value. + if err := d.validateApprovedBy( + ctx, + requestUser, + status.ApprovedByURI, + status.URI, + boostOf.AccountURI, + ); err != nil { + // Error dereferencing means we couldn't + // get the Accept right now or it wasn't + // valid, so we shouldn't store this status. + // + // Do log the error though as it may be + // interesting for admins to see. + log.Info(ctx, "rejecting boost with undereferenceable ApprovedByURI: %v", err) + return onFalse() + } + + // Status has been approved. + status.PendingApproval = util.Ptr(false) return true, nil } + +// validateApprovedBy dereferences the activitystreams Accept at +// the specified IRI, and checks the Accept for validity against +// the provided expectedObject and expectedActor. +// +// Will return either nil if everything looked OK, or an error if +// something went wrong during deref, or if the dereffed Accept +// did not meet expectations. +func (d *Dereferencer) validateApprovedBy( + ctx context.Context, + requestUser string, + approvedByURIStr string, // Eg., "https://example.org/users/someone/accepts/01J2736AWWJ3411CPR833F6D03" + expectedObject string, // Eg., "https://some.instance.example.org/users/someone_else/statuses/01J27414TWV9F7DC39FN8ABB5R" + expectedActor string, // Eg., "https://example.org/users/someone" +) error { + approvedByURI, err := url.Parse(approvedByURIStr) + if err != nil { + err := gtserror.Newf("error parsing approvedByURI: %w", err) + return err + } + + // Don't make calls to the remote if it's blocked. + if blocked, err := d.state.DB.IsDomainBlocked(ctx, approvedByURI.Host); blocked || err != nil { + err := gtserror.Newf("domain %s is blocked", approvedByURI.Host) + return err + } + + transport, err := d.transportController.NewTransportForUsername(ctx, requestUser) + if err != nil { + err := gtserror.Newf("error creating transport: %w", err) + return err + } + + // Make the call to resolve into an Acceptable. + rsp, err := transport.Dereference(ctx, approvedByURI) + if err != nil { + err := gtserror.Newf("error dereferencing %s: %w", approvedByURIStr, err) + return err + } + + acceptable, err := ap.ResolveAcceptable(ctx, rsp.Body) + + // Tidy up rsp body. + _ = rsp.Body.Close() + + if err != nil { + err := gtserror.Newf("error resolving Accept %s: %w", approvedByURIStr, err) + return err + } + + // Extract the URI/ID of the Accept. + acceptURI := ap.GetJSONLDId(acceptable) + acceptURIStr := acceptURI.String() + + // Check whether input URI and final returned URI + // have changed (i.e. we followed some redirects). + rspURL := rsp.Request.URL + rspURLStr := rspURL.String() + if rspURLStr != approvedByURIStr { + // Final URI was different from approvedByURIStr. + // + // Make sure it's at least on the same host as + // what we expected (ie., we weren't redirected + // across domains), and make sure it's the same + // as the ID of the Accept we were returned. + if rspURL.Host != approvedByURI.Host { + err := gtserror.Newf( + "final dereference host %s did not match approvedByURI host %s", + rspURL.Host, approvedByURI.Host, + ) + return err + } + + if acceptURIStr != rspURLStr { + err := gtserror.Newf( + "final dereference uri %s did not match returned Accept ID/URI %s", + rspURLStr, acceptURIStr, + ) + return err + } + } + + // Ensure the Accept URI has the same host + // as the Accept Actor, so we know we're + // not dealing with someone on a different + // domain just pretending to be the Actor. + actorIRIs := ap.GetActorIRIs(acceptable) + if len(actorIRIs) != 1 { + err := gtserror.New("resolved Accept actor(s) length was not 1") + return gtserror.SetMalformed(err) + } + + actorIRI := actorIRIs[0] + actorStr := actorIRI.String() + + if actorIRI.Host != acceptURI.Host { + err := gtserror.Newf( + "Accept Actor %s was not the same host as Accept %s", + actorStr, acceptURIStr, + ) + return err + } + + // Ensure the Accept Actor is who we expect + // it to be, and not someone else trying to + // do an Accept for an interaction with a + // statusable they don't own. + if actorStr != expectedActor { + err := gtserror.Newf( + "Accept Actor %s was not the same as expected actor %s", + actorStr, expectedActor, + ) + return err + } + + // Ensure the Accept Object is what we expect + // it to be, ie., it's Accepting the interaction + // we need it to Accept, and not something else. + objectIRIs := ap.GetObjectIRIs(acceptable) + if len(objectIRIs) != 1 { + err := gtserror.New("resolved Accept object(s) length was not 1") + return err + } + + objectIRI := objectIRIs[0] + objectStr := objectIRI.String() + + if objectStr != expectedObject { + err := gtserror.Newf( + "resolved Accept Object uri %s was not the same as expected object %s", + objectStr, expectedObject, + ) + return err + } + + return nil +} diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go index e26e5955b..60b9cfe58 100644 --- a/internal/federation/federatingdb/accept.go +++ b/internal/federation/federatingdb/accept.go @@ -20,16 +20,31 @@ package federatingdb import ( "context" "errors" - "fmt" + "net/url" "codeberg.org/gruf/go-logger/v2/level" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" ) +func (f *federatingDB) GetAccept( + ctx context.Context, + acceptIRI *url.URL, +) (vocab.ActivityStreamsAccept, error) { + approval, err := f.state.DB.GetInteractionApprovalByURI(ctx, acceptIRI.String()) + if err != nil { + return nil, err + } + return f.converter.InteractionApprovalToASAccept(ctx, approval) +} + func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error { if log.Level() >= level.DEBUG { i, err := marshalItem(accept) @@ -55,100 +70,382 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return nil } - // Iterate all provided objects in the activity. + activityID := ap.GetJSONLDId(accept) + if activityID == nil { + // We need an ID. + const text = "Accept had no id property" + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + + // Iterate all provided objects in the activity, + // handling the ones we know how to handle. for _, object := range ap.ExtractObjects(accept) { + if asType := object.GetType(); asType != nil { + // Check and handle any + // vocab.Type objects. + // nolint:gocritic + switch asType.GetTypeName() { - // Check and handle any vocab.Type objects. - if objType := object.GetType(); objType != nil { - switch objType.GetTypeName() { //nolint:gocritic - + // ACCEPT FOLLOW case ap.ActivityFollow: - // Cast the vocab.Type object to known AS type. - asFollow := objType.(vocab.ActivityStreamsFollow) - - // convert the follow to something we can understand - gtsFollow, err := f.converter.ASFollowToFollow(ctx, asFollow) - if err != nil { - return fmt.Errorf("ACCEPT: error converting asfollow to gtsfollow: %s", err) + if err := f.acceptFollowType( + ctx, + asType, + receivingAcct, + requestingAcct, + ); err != nil { + return err } + } - // Make sure the creator of the original follow - // is the same as whatever inbox this landed in. - if gtsFollow.AccountID != receivingAcct.ID { - return errors.New("ACCEPT: follow account and inbox account were not the same") - } + } else if object.IsIRI() { + // Check and handle any + // IRI type objects. + switch objIRI := object.GetIRI(); { - // Make sure the target of the original follow - // is the same as the account making the request. - if gtsFollow.TargetAccountID != requestingAcct.ID { - return errors.New("ACCEPT: follow target account and requesting account were not the same") - } - - follow, err := f.state.DB.AcceptFollowRequest(ctx, gtsFollow.AccountID, gtsFollow.TargetAccountID) - if err != nil { + // ACCEPT FOLLOW + case uris.IsFollowPath(objIRI): + if err := f.acceptFollowIRI( + ctx, + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { return err } - f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityAccept, - GTSModel: follow, - Receiving: receivingAcct, - Requesting: requestingAcct, - }) - } + // ACCEPT STATUS (reply/boost) + case uris.IsStatusesPath(objIRI): + if err := f.acceptStatusIRI( + ctx, + activityID.String(), + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { + return err + } - continue + // ACCEPT LIKE + case uris.IsLikePath(objIRI): + if err := f.acceptLikeIRI( + ctx, + activityID.String(), + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { + return err + } + } } - - // Check and handle any - // IRI type objects. - if object.IsIRI() { - - // Extract IRI from object. - iri := object.GetIRI() - if !uris.IsFollowPath(iri) { - continue - } - - // Serialize IRI. - iriStr := iri.String() - - // ACCEPT FOLLOW - followReq, err := f.state.DB.GetFollowRequestByURI(ctx, iriStr) - if err != nil { - return fmt.Errorf("ACCEPT: couldn't get follow request with id %s from the database: %s", iriStr, err) - } - - // Make sure the creator of the original follow - // is the same as whatever inbox this landed in. - if followReq.AccountID != receivingAcct.ID { - return errors.New("ACCEPT: follow account and inbox account were not the same") - } - - // Make sure the target of the original follow - // is the same as the account making the request. - if followReq.TargetAccountID != requestingAcct.ID { - return errors.New("ACCEPT: follow target account and requesting account were not the same") - } - - follow, err := f.state.DB.AcceptFollowRequest(ctx, followReq.AccountID, followReq.TargetAccountID) - if err != nil { - return err - } - - f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityAccept, - GTSModel: follow, - Receiving: receivingAcct, - Requesting: requestingAcct, - }) - - continue - } - } return nil } + +func (f *federatingDB) acceptFollowType( + ctx context.Context, + asType vocab.Type, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Cast the vocab.Type object to known AS type. + asFollow := asType.(vocab.ActivityStreamsFollow) + + // Reconstruct the follow. + follow, err := f.converter.ASFollowToFollow(ctx, asFollow) + if err != nil { + err := gtserror.Newf("error converting Follow to *gtsmodel.Follow: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Lock on the Follow URI + // as we may be updating it. + unlock := f.state.FedLocks.Lock(follow.URI) + defer unlock() + + // Make sure the creator of the original follow + // is the same as whatever inbox this landed in. + if follow.AccountID != receivingAcct.ID { + const text = "Follow account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the original follow + // is the same as the account making the request. + if follow.TargetAccountID != requestingAcct.ID { + const text = "Follow target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Accept and get the populated follow back. + follow, err = f.state.DB.AcceptFollowRequest( + ctx, + follow.AccountID, + follow.TargetAccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error accepting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if follow == nil { + // There was no follow request + // to accept, just return 202. + return nil + } + + // Send the accepted follow through + // the processor to do side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: ap.ActivityFollow, + APActivityType: ap.ActivityAccept, + GTSModel: follow, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} + +func (f *federatingDB) acceptFollowIRI( + ctx context.Context, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential Follow + // URI as we may be updating it. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + + // Get the follow req from the db. + followReq, err := f.state.DB.GetFollowRequestByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if followReq == nil { + // We didn't have a follow request + // with this URI, so nothing to do. + // Just return. + return nil + } + + // Make sure the creator of the original follow + // is the same as whatever inbox this landed in. + if followReq.AccountID != receivingAcct.ID { + const text = "Follow account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the original follow + // is the same as the account making the request. + if followReq.TargetAccountID != requestingAcct.ID { + const text = "Follow target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Accept and get the populated follow back. + follow, err := f.state.DB.AcceptFollowRequest( + ctx, + followReq.AccountID, + followReq.TargetAccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error accepting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if follow == nil { + // There was no follow request + // to accept, just return 202. + return nil + } + + // Send the accepted follow through + // the processor to do side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: ap.ActivityFollow, + APActivityType: ap.ActivityAccept, + GTSModel: follow, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} + +func (f *federatingDB) acceptStatusIRI( + ctx context.Context, + activityID string, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential status + // URI as we may be updating it. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + + // Get the status from the db. + status, err := f.state.DB.GetStatusByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if status == nil { + // We didn't have a status with + // this URI, so nothing to do. + // Just return. + return nil + } + + if !status.IsLocal() { + // We don't process Accepts of statuses + // that weren't created on our instance. + // Just return. + return nil + } + + if util.PtrOrValue(status.PendingApproval, false) { + // Status doesn't need approval or it's + // already been approved by an Accept. + // Just return. + return nil + } + + // Make sure the creator of the original status + // is the same as the inbox processing the Accept; + // this also ensures the status is local. + if status.AccountID != receivingAcct.ID { + const text = "status author account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the interaction (reply/boost) + // is the same as the account doing the Accept. + if status.BoostOfAccountID != requestingAcct.ID && + status.InReplyToAccountID != requestingAcct.ID { + const text = "status reply to or boost of account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Mark the status as approved by this Accept URI. + status.PendingApproval = util.Ptr(false) + status.ApprovedByURI = activityID + if err := f.state.DB.UpdateStatus( + ctx, + status, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error accepting status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + var apObjectType string + if status.InReplyToID != "" { + // Accepting a Reply. + apObjectType = ap.ObjectNote + } else { + // Accepting an Announce. + apObjectType = ap.ActivityAnnounce + } + + // Send the now-approved status through to the + // fedi worker again to process side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: apObjectType, + APActivityType: ap.ActivityAccept, + GTSModel: status, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} + +func (f *federatingDB) acceptLikeIRI( + ctx context.Context, + activityID string, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential Like + // URI as we may be updating it. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + + // Get the fave from the db. + fave, err := f.state.DB.GetStatusFaveByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting fave: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if fave == nil { + // We didn't have a fave with + // this URI, so nothing to do. + // Just return. + return nil + } + + if !fave.Account.IsLocal() { + // We don't process Accepts of Likes + // that weren't created on our instance. + // Just return. + return nil + } + + if !util.PtrOrValue(fave.PendingApproval, false) { + // Like doesn't need approval or it's + // already been approved by an Accept. + // Just return. + return nil + } + + // Make sure the creator of the original Like + // is the same as the inbox processing the Accept; + // this also ensures the Like is local. + if fave.AccountID != receivingAcct.ID { + const text = "fave creator account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the Like is the + // same as the account doing the Accept. + if fave.TargetAccountID != requestingAcct.ID { + const text = "status fave target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Mark the fave as approved by this Accept URI. + fave.PendingApproval = util.Ptr(false) + fave.ApprovedByURI = activityID + if err := f.state.DB.UpdateStatusFave( + ctx, + fave, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error accepting status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Send the now-approved fave through to the + // fedi worker again to process side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: ap.ActivityLike, + APActivityType: ap.ActivityAccept, + GTSModel: fave, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} diff --git a/internal/federation/federatingdb/db.go b/internal/federation/federatingdb/db.go index 12bd5a376..3388d7a03 100644 --- a/internal/federation/federatingdb/db.go +++ b/internal/federation/federatingdb/db.go @@ -19,6 +19,7 @@ package federatingdb import ( "context" + "net/url" "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams/vocab" @@ -43,6 +44,12 @@ type DB interface { Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error Move(ctx context.Context, move vocab.ActivityStreamsMove) error + + /* + Extra/convenience functionality. + */ + + GetAccept(ctx context.Context, acceptIRI *url.URL) (vocab.ActivityStreamsAccept, error) } // FederatingDB uses the given state interface diff --git a/internal/federation/federatingdb/get.go b/internal/federation/federatingdb/get.go index eba58853f..5dcebb877 100644 --- a/internal/federation/federatingdb/get.go +++ b/internal/federation/federatingdb/get.go @@ -37,22 +37,30 @@ func (f *federatingDB) Get(ctx context.Context, id *url.URL) (value vocab.Type, l.Debug("entering Get") switch { + case uris.IsUserPath(id): acct, err := f.state.DB.GetAccountByURI(ctx, id.String()) if err != nil { return nil, err } return f.converter.AccountToAS(ctx, acct) + case uris.IsStatusesPath(id): status, err := f.state.DB.GetStatusByURI(ctx, id.String()) if err != nil { return nil, err } return f.converter.StatusToAS(ctx, status) + case uris.IsFollowersPath(id): return f.Followers(ctx, id) + case uris.IsFollowingPath(id): return f.Following(ctx, id) + + case uris.IsAcceptsPath(id): + return f.GetAccept(ctx, id) + default: return nil, fmt.Errorf("federatingDB: could not Get %s", id.String()) } diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index 221663ccd..5f50fb046 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -68,6 +68,7 @@ type Status struct { Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s) InteractionPolicy *InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers. PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed. + PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB. ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to. } diff --git a/internal/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go index 644b3ca63..9d6c6335b 100644 --- a/internal/gtsmodel/statusfave.go +++ b/internal/gtsmodel/statusfave.go @@ -32,5 +32,6 @@ type StatusFave struct { Status *Status `bun:"-"` // the faved status URI string `bun:",nullzero,notnull,unique"` // ActivityPub URI of this fave PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then Like must be Approved by the like-ee before being fully distributed. + PreApproved bool `bun:"-"` // If true, then fave targets a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB. ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves this Like. } diff --git a/internal/processing/fedi/accept.go b/internal/processing/fedi/accept.go new file mode 100644 index 000000000..72d810f94 --- /dev/null +++ b/internal/processing/fedi/accept.go @@ -0,0 +1,84 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package fedi + +import ( + "context" + "errors" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// AcceptGet handles the getting of a fedi/activitypub +// representation of a local interaction approval. +// +// It performs appropriate authentication before +// returning a JSON serializable interface. +func (p *Processor) AcceptGet( + ctx context.Context, + requestedUser string, + approvalID string, +) (interface{}, gtserror.WithCode) { + // Authenticate incoming request, getting related accounts. + auth, errWithCode := p.authenticate(ctx, requestedUser) + if errWithCode != nil { + return nil, errWithCode + } + + if auth.handshakingURI != nil { + // We're currently handshaking, which means + // we don't know this account yet. This should + // be a very rare race condition. + err := gtserror.Newf("network race handshaking %s", auth.handshakingURI) + return nil, gtserror.NewErrorInternalError(err) + } + + receivingAcct := auth.receivingAcct + + approval, err := p.state.DB.GetInteractionApprovalByID(ctx, approvalID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting approval %s: %w", approvalID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if approval.AccountID != receivingAcct.ID { + const text = "approval does not belong to receiving account" + return nil, gtserror.NewErrorNotFound(errors.New(text)) + } + + if approval == nil { + err := gtserror.Newf("approval %s not found", approvalID) + return nil, gtserror.NewErrorNotFound(err) + } + + accept, err := p.converter.InteractionApprovalToASAccept(ctx, approval) + if err != nil { + err := gtserror.Newf("error converting approval: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + data, err := ap.Serialize(accept) + if err != nil { + err := gtserror.Newf("error serializing accept: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return data, nil +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 5afcf0721..0afe8356b 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -177,8 +177,7 @@ func NewProcessor( visFilter *visibility.Filter, intFilter *interaction.Filter, ) *Processor { - var parseMentionFunc = GetParseMentionFunc(state, federator) - + parseMentionFunc := GetParseMentionFunc(state, federator) processor := &Processor{ converter: converter, oauthServer: oauthServer, diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go index d6a0c2457..1b6e8bd47 100644 --- a/internal/processing/status/boost.go +++ b/internal/processing/status/boost.go @@ -104,9 +104,18 @@ func (p *Processor) BoostCreate( // We're permitted to do this, but since // we matched due to presence in a followers // or following collection, we should mark - // as pending approval and wait for an accept. + // as pending approval and wait until we can + // prove it's been Accepted by the target. pendingApproval = true + if *target.Local { + // If the target is local we don't need + // to wait for an Accept from remote, + // we can just preapprove it and have + // the processor create the Accept. + boost.PreApproved = true + } + case policyResult.Permitted(): // We're permitted to do this // based on another kind of match. diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 10e19ac43..11dece87d 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -221,9 +221,18 @@ func (p *Processor) processInReplyTo(ctx context.Context, requester *gtsmodel.Ac // We're permitted to do this, but since // we matched due to presence in a followers // or following collection, we should mark - // as pending approval and wait for an accept. + // as pending approval and wait until we can + // prove it's been Accepted by the target. pendingApproval = true + if *inReplyTo.Local { + // If the target is local we don't need + // to wait for an Accept from remote, + // we can just preapprove it and have + // the processor create the Accept. + status.PreApproved = true + } + case policyResult.Permitted(): // We're permitted to do this // based on another kind of match. diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go index 0f5a72b7d..497c4d465 100644 --- a/internal/processing/status/fave.go +++ b/internal/processing/status/fave.go @@ -103,8 +103,13 @@ func (p *Processor) FaveCreate( return nil, gtserror.NewErrorForbidden(err, errText) } - // Derive pendingApproval status. - var pendingApproval bool + // Derive pendingApproval + // and preapproved status. + var ( + pendingApproval bool + preApproved bool + ) + switch { case policyResult.WithApproval(): // We're allowed to do @@ -115,9 +120,18 @@ func (p *Processor) FaveCreate( // We're permitted to do this, but since // we matched due to presence in a followers // or following collection, we should mark - // as pending approval and wait for an accept. + // as pending approval and wait until we can + // prove it's been Accepted by the target. pendingApproval = true + if *status.Local { + // If the target is local we don't need + // to wait for an Accept from remote, + // we can just preapprove it and have + // the processor create the Accept. + preApproved = true + } + case policyResult.Permitted(): // We're permitted to do this // based on another kind of match. @@ -138,6 +152,7 @@ func (p *Processor) FaveCreate( StatusID: status.ID, Status: status, URI: uris.GenerateURIForLike(requester.Username, faveID), + PreApproved: preApproved, PendingApproval: &pendingApproval, } diff --git a/internal/processing/workers/federate.go b/internal/processing/workers/federate.go index 3538c9958..d71bb0a83 100644 --- a/internal/processing/workers/federate.go +++ b/internal/processing/workers/federate.go @@ -23,12 +23,14 @@ import ( "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // federate wraps functions for federating @@ -135,6 +137,12 @@ func (f *federate) DeleteAccount(ctx context.Context, account *gtsmodel.Account) return nil } +// CreateStatus sends the given status out to relevant +// recipients with the Outbox of the status creator. +// +// If the status is pending approval, then it will be +// sent **ONLY** to the inbox of the account it replies to, +// ignoring shared inboxes. func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) error { // Do nothing if the status // shouldn't be federated. @@ -153,18 +161,32 @@ func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) er return gtserror.Newf("error populating status: %w", err) } - // Parse the outbox URI of the status author. - outboxIRI, err := parseURI(status.Account.OutboxURI) - if err != nil { - return err - } - // Convert status to AS Statusable implementing type. statusable, err := f.converter.StatusToAS(ctx, status) if err != nil { return gtserror.Newf("error converting status to Statusable: %w", err) } + // If status is pending approval, + // it must be a reply. Deliver it + // **ONLY** to the account it replies + // to, on behalf of the replier. + if util.PtrOrValue(status.PendingApproval, false) { + return f.deliverToInboxOnly( + ctx, + status.Account, + status.InReplyToAccount, + // Status has to be wrapped in Create activity. + typeutils.WrapStatusableInCreate(statusable, false), + ) + } + + // Parse the outbox URI of the status author. + outboxIRI, err := parseURI(status.Account.OutboxURI) + if err != nil { + return err + } + // Send a Create activity with Statusable via the Actor's outbox. create := typeutils.WrapStatusableInCreate(statusable, false) if _, err := f.FederatingActor().Send(ctx, outboxIRI, create); err != nil { @@ -672,6 +694,12 @@ func (f *federate) RejectFollow(ctx context.Context, follow *gtsmodel.Follow) er return nil } +// Like sends the given fave out to relevant +// recipients with the Outbox of the status creator. +// +// If the fave is pending approval, then it will be +// sent **ONLY** to the inbox of the account it faves, +// ignoring shared inboxes. func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error { // Populate model. if err := f.state.DB.PopulateStatusFave(ctx, fave); err != nil { @@ -684,18 +712,30 @@ func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error { return nil } - // Parse relevant URI(s). - outboxIRI, err := parseURI(fave.Account.OutboxURI) - if err != nil { - return err - } - // Create the ActivityStreams Like. like, err := f.converter.FaveToAS(ctx, fave) if err != nil { return gtserror.Newf("error converting fave to AS Like: %w", err) } + // If fave is pending approval, + // deliver it **ONLY** to the account + // it faves, on behalf of the faver. + if util.PtrOrValue(fave.PendingApproval, false) { + return f.deliverToInboxOnly( + ctx, + fave.Account, + fave.TargetAccount, + like, + ) + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(fave.Account.OutboxURI) + if err != nil { + return err + } + // Send the Like via the Actor's outbox. if _, err := f.FederatingActor().Send( ctx, outboxIRI, like, @@ -709,6 +749,12 @@ func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error { return nil } +// Announce sends the given boost out to relevant +// recipients with the Outbox of the status creator. +// +// If the boost is pending approval, then it will be +// sent **ONLY** to the inbox of the account it boosts, +// ignoring shared inboxes. func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { // Populate model. if err := f.state.DB.PopulateStatus(ctx, boost); err != nil { @@ -721,12 +767,6 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { return nil } - // Parse relevant URI(s). - outboxIRI, err := parseURI(boost.Account.OutboxURI) - if err != nil { - return err - } - // Create the ActivityStreams Announce. announce, err := f.converter.BoostToAS( ctx, @@ -738,6 +778,24 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { return gtserror.Newf("error converting boost to AS: %w", err) } + // If announce is pending approval, + // deliver it **ONLY** to the account + // it boosts, on behalf of the booster. + if util.PtrOrValue(boost.PendingApproval, false) { + return f.deliverToInboxOnly( + ctx, + boost.Account, + boost.BoostOfAccount, + announce, + ) + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(boost.Account.OutboxURI) + if err != nil { + return err + } + // Send the Announce via the Actor's outbox. if _, err := f.FederatingActor().Send( ctx, outboxIRI, announce, @@ -751,6 +809,57 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { return nil } +// deliverToInboxOnly delivers the given Activity +// *only* to the inbox of targetAcct, on behalf of +// sendingAcct, regardless of the `to` and `cc` values +// set on the activity. This should be used specifically +// for sending "pending approval" activities. +func (f *federate) deliverToInboxOnly( + ctx context.Context, + sendingAcct *gtsmodel.Account, + targetAcct *gtsmodel.Account, + t vocab.Type, +) error { + if targetAcct.IsLocal() { + // If this is a local target, + // they've already received it. + return nil + } + + toInbox, err := url.Parse(targetAcct.InboxURI) + if err != nil { + return gtserror.Newf( + "error parsing target inbox uri: %w", + err, + ) + } + + tsport, err := f.TransportController().NewTransportForUsername( + ctx, + sendingAcct.Username, + ) + if err != nil { + return gtserror.Newf( + "error getting transport to deliver activity %T to target inbox %s: %w", + t, targetAcct.InboxURI, err, + ) + } + + m, err := ap.Serialize(t) + if err != nil { + return err + } + + if err := tsport.Deliver(ctx, m, toInbox); err != nil { + return gtserror.Newf( + "error delivering activity %T to target inbox %s: %w", + t, targetAcct.InboxURI, err, + ) + } + + return nil +} + func (f *federate) UpdateAccount(ctx context.Context, account *gtsmodel.Account) error { // Populate model. if err := f.state.DB.PopulateAccount(ctx, account); err != nil { @@ -1015,3 +1124,75 @@ func (f *federate) MoveAccount(ctx context.Context, account *gtsmodel.Account) e return nil } + +func (f *federate) AcceptInteraction( + ctx context.Context, + approval *gtsmodel.InteractionApproval, +) error { + // Populate model. + if err := f.state.DB.PopulateInteractionApproval(ctx, approval); err != nil { + return gtserror.Newf("error populating approval: %w", err) + } + + // Bail if interacting account is ours: + // we've already accepted internally and + // shouldn't send an Accept to ourselves. + if approval.InteractingAccount.IsLocal() { + return nil + } + + // Bail if account isn't ours: + // we can't Accept on another + // instance's behalf. (This + // should never happen but...) + if approval.Account.IsRemote() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(approval.Account.OutboxURI) + if err != nil { + return err + } + + acceptingAcctIRI, err := parseURI(approval.Account.URI) + if err != nil { + return err + } + + interactingAcctURI, err := parseURI(approval.InteractingAccount.URI) + if err != nil { + return err + } + + interactionURI, err := parseURI(approval.InteractionURI) + if err != nil { + return err + } + + // Create a new Accept. + accept := streams.NewActivityStreamsAccept() + + // Set interacted-with account + // as Actor of the Accept. + ap.AppendActorIRIs(accept, acceptingAcctIRI) + + // Set the interacted-with object + // as Object of the Accept. + ap.AppendObjectIRIs(accept, interactionURI) + + // Address the Accept To the interacting acct. + ap.AppendTo(accept, interactingAcctURI) + + // Send the Accept via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, accept, + ); err != nil { + return gtserror.Newf( + "error sending activity %T for %v via outbox %s: %w", + accept, approval.InteractionType, outboxIRI, err, + ) + } + + return nil +} diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index d5d4265e1..7f1b5780c 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -135,6 +135,18 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro // ACCEPT USER (ie., new user+account sign-up) case ap.ObjectProfile: return p.clientAPI.AcceptUser(ctx, cMsg) + + // ACCEPT NOTE/STATUS (ie., accept a reply) + case ap.ObjectNote: + return p.clientAPI.AcceptReply(ctx, cMsg) + + // ACCEPT LIKE + case ap.ActivityLike: + return p.clientAPI.AcceptLike(ctx, cMsg) + + // ACCEPT BOOST + case ap.ActivityAnnounce: + return p.clientAPI.AcceptAnnounce(ctx, cMsg) } // REJECT SOMETHING @@ -236,6 +248,61 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) } + // If pending approval is true then status must + // reply to a status (either one of ours or a + // remote) that requires approval for the reply. + pendingApproval := util.PtrOrValue( + status.PendingApproval, + false, + ) + + switch { + case pendingApproval && !status.PreApproved: + // If approval is required and status isn't + // preapproved, then send out the Create to + // only the replied-to account (if it's remote), + // and/or notify the account that's being + // interacted with (if it's local): they can + // approve or deny the interaction later. + + // Notify *local* account of pending reply. + if err := p.surface.notifyPendingReply(ctx, status); err != nil { + log.Errorf(ctx, "error notifying pending reply: %v", err) + } + + // Send Create to *remote* account inbox ONLY. + if err := p.federate.CreateStatus(ctx, status); err != nil { + log.Errorf(ctx, "error federating pending reply: %v", err) + } + + // Return early. + return nil + + case pendingApproval && status.PreApproved: + // If approval is required and status is + // preapproved, that means this is a reply + // to one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal, + // sending out the Create with the approval + // URI attached. + + // Put approval in the database and + // update the status with approvedBy URI. + approval, err := p.utils.approveReply(ctx, status) + if err != nil { + return gtserror.Newf("error pre-approving reply: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of reply: %w", err) + } + + // Don't return, just continue as normal. + } + // Update stats for the actor account. if err := p.utils.incrementStatusesCount(ctx, cMsg.Origin, status); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -362,6 +429,61 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI return gtserror.Newf("error populating status fave: %w", err) } + // If pending approval is true then fave must + // target a status (either one of ours or a + // remote) that requires approval for the fave. + pendingApproval := util.PtrOrValue( + fave.PendingApproval, + false, + ) + + switch { + case pendingApproval && !fave.PreApproved: + // If approval is required and fave isn't + // preapproved, then send out the Like to + // only the faved account (if it's remote), + // and/or notify the account that's being + // interacted with (if it's local): they can + // approve or deny the interaction later. + + // Notify *local* account of pending reply. + if err := p.surface.notifyPendingFave(ctx, fave); err != nil { + log.Errorf(ctx, "error notifying pending fave: %v", err) + } + + // Send Like to *remote* account inbox ONLY. + if err := p.federate.Like(ctx, fave); err != nil { + log.Errorf(ctx, "error federating pending Like: %v", err) + } + + // Return early. + return nil + + case pendingApproval && fave.PreApproved: + // If approval is required and fave is + // preapproved, that means this is a fave + // of one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal, + // sending out the Like with the approval + // URI attached. + + // Put approval in the database and + // update the fave with approvedBy URI. + approval, err := p.utils.approveFave(ctx, fave) + if err != nil { + return gtserror.Newf("error pre-approving fave: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of fave: %w", err) + } + + // Don't return, just continue as normal. + } + if err := p.surface.notifyFave(ctx, fave); err != nil { log.Errorf(ctx, "error notifying fave: %v", err) } @@ -383,6 +505,61 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) } + // If pending approval is true then status must + // boost a status (either one of ours or a + // remote) that requires approval for the boost. + pendingApproval := util.PtrOrValue( + boost.PendingApproval, + false, + ) + + switch { + case pendingApproval && !boost.PreApproved: + // If approval is required and boost isn't + // preapproved, then send out the Announce to + // only the boosted account (if it's remote), + // and/or notify the account that's being + // interacted with (if it's local): they can + // approve or deny the interaction later. + + // Notify *local* account of pending announce. + if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil { + log.Errorf(ctx, "error notifying pending boost: %v", err) + } + + // Send Announce to *remote* account inbox ONLY. + if err := p.federate.Announce(ctx, boost); err != nil { + log.Errorf(ctx, "error federating pending Announce: %v", err) + } + + // Return early. + return nil + + case pendingApproval && boost.PreApproved: + // If approval is required and boost is + // preapproved, that means this is a boost + // of one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal, + // sending out the Create with the approval + // URI attached. + + // Put approval in the database and + // update the boost with approvedBy URI. + approval, err := p.utils.approveAnnounce(ctx, boost) + if err != nil { + return gtserror.Newf("error pre-approving boost: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of boost: %w", err) + } + + // Don't return, just continue as normal. + } + // Update stats for the actor account. if err := p.utils.incrementStatusesCount(ctx, cMsg.Origin, boost); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -874,3 +1051,18 @@ func (p *clientAPI) RejectUser(ctx context.Context, cMsg *messages.FromClientAPI return nil } + +func (p *clientAPI) AcceptLike(ctx context.Context, cMsg *messages.FromClientAPI) error { + // TODO + return nil +} + +func (p *clientAPI) AcceptReply(ctx context.Context, cMsg *messages.FromClientAPI) error { + // TODO + return nil +} + +func (p *clientAPI) AcceptAnnounce(ctx context.Context, cMsg *messages.FromClientAPI) error { + // TODO + return nil +} diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index ac4003f6a..63d1f0d16 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -122,11 +122,23 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF // ACCEPT SOMETHING case ap.ActivityAccept: - switch fMsg.APObjectType { //nolint:gocritic + switch fMsg.APObjectType { - // ACCEPT FOLLOW + // ACCEPT (pending) FOLLOW case ap.ActivityFollow: return p.fediAPI.AcceptFollow(ctx, fMsg) + + // ACCEPT (pending) LIKE + case ap.ActivityLike: + return p.fediAPI.AcceptLike(ctx, fMsg) + + // ACCEPT (pending) REPLY + case ap.ObjectNote: + return p.fediAPI.AcceptReply(ctx, fMsg) + + // ACCEPT (pending) ANNOUNCE + case ap.ActivityAnnounce: + return p.fediAPI.AcceptAnnounce(ctx, fMsg) } // DELETE SOMETHING @@ -216,6 +228,52 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) return nil } + // If pending approval is true then + // status must reply to a LOCAL status + // that requires approval for the reply. + pendingApproval := util.PtrOrValue( + status.PendingApproval, + false, + ) + + switch { + case pendingApproval && !status.PreApproved: + // If approval is required and status isn't + // preapproved, then just notify the account + // that's being interacted with: they can + // approve or deny the interaction later. + + // Notify *local* account of pending reply. + if err := p.surface.notifyPendingReply(ctx, status); err != nil { + log.Errorf(ctx, "error notifying pending reply: %v", err) + } + + // Return early. + return nil + + case pendingApproval && status.PreApproved: + // If approval is required and status is + // preapproved, that means this is a reply + // to one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal. + + // Put approval in the database and + // update the status with approvedBy URI. + approval, err := p.utils.approveReply(ctx, status) + if err != nil { + return gtserror.Newf("error pre-approving reply: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of reply: %w", err) + } + + // Don't return, just continue as normal. + } + // Update stats for the remote account. if err := p.utils.incrementStatusesCount(ctx, fMsg.Requesting, status); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -348,6 +406,52 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er return gtserror.Newf("error populating status fave: %w", err) } + // If pending approval is true then + // fave must target a LOCAL status + // that requires approval for the fave. + pendingApproval := util.PtrOrValue( + fave.PendingApproval, + false, + ) + + switch { + case pendingApproval && !fave.PreApproved: + // If approval is required and fave isn't + // preapproved, then just notify the account + // that's being interacted with: they can + // approve or deny the interaction later. + + // Notify *local* account of pending fave. + if err := p.surface.notifyPendingFave(ctx, fave); err != nil { + log.Errorf(ctx, "error notifying pending fave: %v", err) + } + + // Return early. + return nil + + case pendingApproval && fave.PreApproved: + // If approval is required and fave is + // preapproved, that means this is a fave + // of one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal. + + // Put approval in the database and + // update the fave with approvedBy URI. + approval, err := p.utils.approveFave(ctx, fave) + if err != nil { + return gtserror.Newf("error pre-approving fave: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of fave: %w", err) + } + + // Don't return, just continue as normal. + } + if err := p.surface.notifyFave(ctx, fave); err != nil { log.Errorf(ctx, "error notifying fave: %v", err) } @@ -365,8 +469,9 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) } - // Dereference status that this boosts, note - // that this will handle storing the boost in + // Dereference into a boost wrapper status. + // + // Note: this will handle storing the boost in // the db, and dereferencing the target status // ancestors / descendants where appropriate. var err error @@ -376,8 +481,10 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI fMsg.Receiving.Username, ) if err != nil { - if gtserror.IsUnretrievable(err) { - // Boosted status domain blocked, nothing to do. + if gtserror.IsUnretrievable(err) || + gtserror.NotPermitted(err) { + // Boosted status domain blocked, or + // otherwise not permitted, nothing to do. log.Debugf(ctx, "skipping announce: %v", err) return nil } @@ -386,6 +493,52 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI return gtserror.Newf("error dereferencing announce: %w", err) } + // If pending approval is true then + // boost must target a LOCAL status + // that requires approval for the boost. + pendingApproval := util.PtrOrValue( + boost.PendingApproval, + false, + ) + + switch { + case pendingApproval && !boost.PreApproved: + // If approval is required and boost isn't + // preapproved, then just notify the account + // that's being interacted with: they can + // approve or deny the interaction later. + + // Notify *local* account of pending announce. + if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil { + log.Errorf(ctx, "error notifying pending boost: %v", err) + } + + // Return early. + return nil + + case pendingApproval && boost.PreApproved: + // If approval is required and status is + // preapproved, that means this is a boost + // of one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal. + + // Put approval in the database and + // update the boost with approvedBy URI. + approval, err := p.utils.approveAnnounce(ctx, boost) + if err != nil { + return gtserror.Newf("error pre-approving boost: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of boost: %w", err) + } + + // Don't return, just continue as normal. + } + // Update stats for the remote account. if err := p.utils.incrementStatusesCount(ctx, fMsg.Requesting, boost); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -549,6 +702,68 @@ func (p *fediAPI) AcceptFollow(ctx context.Context, fMsg *messages.FromFediAPI) return nil } +func (p *fediAPI) AcceptLike(ctx context.Context, fMsg *messages.FromFediAPI) error { + // TODO: Add something here if we ever implement sending out Likes to + // followers more broadly and not just the owner of the Liked status. + return nil +} + +func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) error { + status, ok := fMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) + } + + // Update stats for the actor account. + if err := p.utils.incrementStatusesCount(ctx, status.Account, status); err != nil { + log.Errorf(ctx, "error updating account stats: %v", err) + } + + // Timeline and notify the status. + if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + + // Interaction counts changed on the replied-to status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + + // Send out the reply again, fully this time. + if err := p.federate.CreateStatus(ctx, status); err != nil { + log.Errorf(ctx, "error federating announce: %v", err) + } + + return nil +} + +func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI) error { + boost, ok := fMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) + } + + // Update stats for the actor account. + if err := p.utils.incrementStatusesCount(ctx, boost.Account, boost); err != nil { + log.Errorf(ctx, "error updating account stats: %v", err) + } + + // Timeline and notify the boost wrapper status. + if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + + // Send out the boost again, fully this time. + if err := p.federate.Announce(ctx, boost); err != nil { + log.Errorf(ctx, "error federating announce: %v", err) + } + + return nil +} + func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) error { // Cast the existing Status model attached to msg. existing, ok := fMsg.GTSModel.(*gtsmodel.Status) diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go index edeb4b57e..872ccca65 100644 --- a/internal/processing/workers/surfacenotify.go +++ b/internal/processing/workers/surfacenotify.go @@ -32,6 +32,62 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/util" ) +// notifyPendingReply notifies the account replied-to +// by the given status that they have a new reply, +// and that approval is pending. +func (s *Surface) notifyPendingReply( + ctx context.Context, + status *gtsmodel.Status, +) error { + // Beforehand, ensure the passed status is fully populated. + if err := s.State.DB.PopulateStatus(ctx, status); err != nil { + return gtserror.Newf("error populating status %s: %w", status.ID, err) + } + + if status.InReplyToAccount.IsRemote() { + // Don't notify + // remote accounts. + return nil + } + + if status.AccountID == status.InReplyToAccountID { + // Don't notify + // self-replies. + return nil + } + + // Ensure thread not muted + // by replied-to account. + muted, err := s.State.DB.IsThreadMutedByAccount( + ctx, + status.ThreadID, + status.InReplyToAccountID, + ) + if err != nil { + return gtserror.Newf("error checking status thread mute %s: %w", status.ThreadID, err) + } + + if muted { + // The replied-to account + // has muted the thread. + // Don't pester them. + return nil + } + + // notify mentioned + // by status author. + if err := s.Notify(ctx, + gtsmodel.NotificationPendingReply, + status.InReplyToAccount, + status.Account, + status.ID, + ); err != nil { + return gtserror.Newf("error notifying replied-to account %s: %w", status.InReplyToAccountID, err) + } + + return nil +} + // notifyMentions iterates through mentions on the // given status, and notifies each mentioned account // that they have a new mention. @@ -181,36 +237,13 @@ func (s *Surface) notifyFave( ctx context.Context, fave *gtsmodel.StatusFave, ) error { - if fave.TargetAccountID == fave.AccountID { - // Self-fave, nothing to do. - return nil - } - - // Beforehand, ensure the passed status fave is fully populated. - if err := s.State.DB.PopulateStatusFave(ctx, fave); err != nil { - return gtserror.Newf("error populating fave %s: %w", fave.ID, err) - } - - if fave.TargetAccount.IsRemote() { - // no need to notify - // remote accounts. - return nil - } - - // Ensure favee hasn't - // muted the thread. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, - fave.Status.ThreadID, - fave.TargetAccountID, - ) + notifyable, err := s.notifyableFave(ctx, fave) if err != nil { - return gtserror.Newf("error checking status thread mute %s: %w", fave.StatusID, err) + return err } - if muted { - // Favee doesn't want - // notifs for this thread. + if !notifyable { + // Nothing to do. return nil } @@ -228,31 +261,167 @@ func (s *Surface) notifyFave( return nil } +// notifyPendingFave notifies the target of the +// given fave that their status has been faved +// and that approval is required. +func (s *Surface) notifyPendingFave( + ctx context.Context, + fave *gtsmodel.StatusFave, +) error { + notifyable, err := s.notifyableFave(ctx, fave) + if err != nil { + return err + } + + if !notifyable { + // Nothing to do. + return nil + } + + // notify status author + // of fave by account. + if err := s.Notify(ctx, + gtsmodel.NotificationPendingFave, + fave.TargetAccount, + fave.Account, + fave.StatusID, + ); err != nil { + return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err) + } + + return nil +} + +// notifyableFave checks that the given +// fave should be notified, taking account +// of localness of receiving account, and mutes. +func (s *Surface) notifyableFave( + ctx context.Context, + fave *gtsmodel.StatusFave, +) (bool, error) { + if fave.TargetAccountID == fave.AccountID { + // Self-fave, nothing to do. + return false, nil + } + + // Beforehand, ensure the passed status fave is fully populated. + if err := s.State.DB.PopulateStatusFave(ctx, fave); err != nil { + return false, gtserror.Newf("error populating fave %s: %w", fave.ID, err) + } + + if fave.TargetAccount.IsRemote() { + // no need to notify + // remote accounts. + return false, nil + } + + // Ensure favee hasn't + // muted the thread. + muted, err := s.State.DB.IsThreadMutedByAccount( + ctx, + fave.Status.ThreadID, + fave.TargetAccountID, + ) + if err != nil { + return false, gtserror.Newf("error checking status thread mute %s: %w", fave.StatusID, err) + } + + if muted { + // Favee doesn't want + // notifs for this thread. + return false, nil + } + + return true, nil +} + // notifyAnnounce notifies the status boost target // account that their status has been boosted. func (s *Surface) notifyAnnounce( ctx context.Context, - status *gtsmodel.Status, + boost *gtsmodel.Status, ) error { + notifyable, err := s.notifyableAnnounce(ctx, boost) + if err != nil { + return err + } + + if !notifyable { + // Nothing to do. + return nil + } + + // notify status author + // of boost by account. + if err := s.Notify(ctx, + gtsmodel.NotificationReblog, + boost.BoostOfAccount, + boost.Account, + boost.ID, + ); err != nil { + return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err) + } + + return nil +} + +// notifyPendingAnnounce notifies the status boost +// target account that their status has been boosted, +// and that the boost requires approval. +func (s *Surface) notifyPendingAnnounce( + ctx context.Context, + boost *gtsmodel.Status, +) error { + notifyable, err := s.notifyableAnnounce(ctx, boost) + if err != nil { + return err + } + + if !notifyable { + // Nothing to do. + return nil + } + + // notify status author + // of boost by account. + if err := s.Notify(ctx, + gtsmodel.NotificationPendingReblog, + boost.BoostOfAccount, + boost.Account, + boost.ID, + ); err != nil { + return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err) + } + + return nil +} + +// notifyableAnnounce checks that the given +// announce should be notified, taking account +// of localness of receiving account, and mutes. +func (s *Surface) notifyableAnnounce( + ctx context.Context, + status *gtsmodel.Status, +) (bool, error) { if status.BoostOfID == "" { // Not a boost, nothing to do. - return nil + return false, nil } if status.BoostOfAccountID == status.AccountID { // Self-boost, nothing to do. - return nil + return false, nil } // Beforehand, ensure the passed status is fully populated. if err := s.State.DB.PopulateStatus(ctx, status); err != nil { - return gtserror.Newf("error populating status %s: %w", status.ID, err) + return false, gtserror.Newf("error populating status %s: %w", status.ID, err) } if status.BoostOfAccount.IsRemote() { // no need to notify // remote accounts. - return nil + return false, nil } // Ensure boostee hasn't @@ -264,27 +433,16 @@ func (s *Surface) notifyAnnounce( ) if err != nil { - return gtserror.Newf("error checking status thread mute %s: %w", status.BoostOfID, err) + return false, gtserror.Newf("error checking status thread mute %s: %w", status.BoostOfID, err) } if muted { // Boostee doesn't want // notifs for this thread. - return nil + return false, nil } - // notify status author - // of boost by account. - if err := s.Notify(ctx, - gtsmodel.NotificationReblog, - status.BoostOfAccount, - status.Account, - status.ID, - ); err != nil { - return gtserror.Newf("error notifying status author %s: %w", status.BoostOfAccountID, err) - } - - return nil + return true, nil } func (s *Surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status) error { diff --git a/internal/processing/workers/util.go b/internal/processing/workers/util.go index 915370976..994242d37 100644 --- a/internal/processing/workers/util.go +++ b/internal/processing/workers/util.go @@ -26,10 +26,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // util provides util functions used by both @@ -498,3 +501,129 @@ func (u *utils) decrementFollowRequestsCount( return nil } + +// approveFave stores + returns an +// interactionApproval for a fave. +func (u *utils) approveFave( + ctx context.Context, + fave *gtsmodel.StatusFave, +) (*gtsmodel.InteractionApproval, error) { + id := id.NewULID() + + approval := >smodel.InteractionApproval{ + ID: id, + AccountID: fave.TargetAccountID, + Account: fave.TargetAccount, + InteractingAccountID: fave.AccountID, + InteractingAccount: fave.Account, + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id), + } + + if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { + err := gtserror.Newf("db error inserting interaction approval: %w", err) + return nil, err + } + + // Mark the fave itself as now approved. + fave.PendingApproval = util.Ptr(false) + fave.PreApproved = false + fave.ApprovedByURI = approval.URI + + if err := u.state.DB.UpdateStatusFave( + ctx, + fave, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating status fave: %w", err) + return nil, err + } + + return approval, nil +} + +// approveReply stores + returns an +// interactionApproval for a reply. +func (u *utils) approveReply( + ctx context.Context, + status *gtsmodel.Status, +) (*gtsmodel.InteractionApproval, error) { + id := id.NewULID() + + approval := >smodel.InteractionApproval{ + ID: id, + AccountID: status.InReplyToAccountID, + Account: status.InReplyToAccount, + InteractingAccountID: status.AccountID, + InteractingAccount: status.Account, + InteractionURI: status.URI, + InteractionType: gtsmodel.InteractionReply, + URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id), + } + + if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { + err := gtserror.Newf("db error inserting interaction approval: %w", err) + return nil, err + } + + // Mark the status itself as now approved. + status.PendingApproval = util.Ptr(false) + status.PreApproved = false + status.ApprovedByURI = approval.URI + + if err := u.state.DB.UpdateStatus( + ctx, + status, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating status: %w", err) + return nil, err + } + + return approval, nil +} + +// approveAnnounce stores + returns an +// interactionApproval for an announce. +func (u *utils) approveAnnounce( + ctx context.Context, + boost *gtsmodel.Status, +) (*gtsmodel.InteractionApproval, error) { + id := id.NewULID() + + approval := >smodel.InteractionApproval{ + ID: id, + AccountID: boost.BoostOfAccountID, + Account: boost.BoostOfAccount, + InteractingAccountID: boost.AccountID, + InteractingAccount: boost.Account, + InteractionURI: boost.URI, + InteractionType: gtsmodel.InteractionReply, + URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id), + } + + if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { + err := gtserror.Newf("db error inserting interaction approval: %w", err) + return nil, err + } + + // Mark the status itself as now approved. + boost.PendingApproval = util.Ptr(false) + boost.PreApproved = false + boost.ApprovedByURI = approval.URI + + if err := u.state.DB.UpdateStatus( + ctx, + boost, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating boost wrapper status: %w", err) + return nil, err + } + + return approval, nil +} diff --git a/internal/regexes/regexes.go b/internal/regexes/regexes.go index aca502345..799557657 100644 --- a/internal/regexes/regexes.go +++ b/internal/regexes/regexes.go @@ -38,6 +38,7 @@ const ( follow = "follow" blocks = "blocks" reports = "reports" + accepts = "accepts" schemes = `(http|https)://` // Allowed URI protocols for parsing links in text. alphaNumeric = `\p{L}\p{M}*|\p{N}` // A single number or script character in any language, including chars with accents. @@ -71,6 +72,7 @@ const ( followPath = userPathPrefix + `/` + follow + `/(` + ulid + `)$` likePath = userPathPrefix + `/` + liked + `/(` + ulid + `)$` statusesPath = userPathPrefix + `/` + statuses + `/(` + ulid + `)$` + acceptsPath = userPathPrefix + `/` + accepts + `/(` + ulid + `)$` blockPath = userPathPrefix + `/` + blocks + `/(` + ulid + `)$` reportPath = `^/?` + reports + `/(` + ulid + `)$` filePath = `^/?(` + ulid + `)/([a-z]+)/([a-z]+)/(` + ulid + `)\.([a-z0-9]+)$` @@ -158,6 +160,10 @@ var ( // from eg /reports/01GP3AWY4CRDVRNZKW0TEAMB5R ReportPath = regexp.MustCompile(reportPath) + // ReportPath parses a path that validates and captures the username part and the ulid part + // from eg /users/example_username/accepts/01GP3AWY4CRDVRNZKW0TEAMB5R + AcceptsPath = regexp.MustCompile(acceptsPath) + // FilePath parses a file storage path of the form [ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME] // eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg // It captures the account id, media type, media size, file name, and file extension, eg diff --git a/internal/transport/controller.go b/internal/transport/controller.go index 519298d8e..3a529a8f3 100644 --- a/internal/transport/controller.go +++ b/internal/transport/controller.go @@ -204,6 +204,38 @@ func (c *controller) dereferenceLocalUser(ctx context.Context, iri *url.URL) (*h return rsp, nil } +// dereferenceLocalAccept is a shortcut to dereference an accept created +// by an account on this instance, without making any external api/http calls. +// +// It is passed to new transports, and should only be invoked when the iri.Host == this host. +func (c *controller) dereferenceLocalAccept(ctx context.Context, iri *url.URL) (*http.Response, error) { + accept, err := c.fedDB.GetAccept(ctx, iri) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, err + } + + if accept == nil { + // Return a generic 404 not found response. + rsp := craftResponse(iri, http.StatusNotFound) + return rsp, nil + } + + i, err := ap.Serialize(accept) + if err != nil { + return nil, err + } + + b, err := json.Marshal(i) + if err != nil { + return nil, err + } + + // Return a response with AS data as body. + rsp := craftResponse(iri, http.StatusOK) + rsp.Body = io.NopCloser(bytes.NewReader(b)) + return rsp, nil +} + func craftResponse(url *url.URL, code int) *http.Response { rsp := new(http.Response) rsp.Request = new(http.Request) diff --git a/internal/transport/dereference.go b/internal/transport/dereference.go index 952791f70..8cc1f2103 100644 --- a/internal/transport/dereference.go +++ b/internal/transport/dereference.go @@ -29,17 +29,26 @@ import ( ) func (t *transport) Dereference(ctx context.Context, iri *url.URL) (*http.Response, error) { - // if the request is to us, we can shortcut for certain URIs rather than going through - // the normal request flow, thereby saving time and energy + // If the request is to us, we can shortcut for + // certain URIs rather than going through the normal + // request flow, thereby saving time and energy. if iri.Host == config.GetHost() { - if uris.IsFollowersPath(iri) { - // the request is for followers of one of our accounts, which we can shortcut - return t.controller.dereferenceLocalFollowers(ctx, iri) - } + switch { - if uris.IsUserPath(iri) { - // the request is for one of our accounts, which we can shortcut + case uris.IsFollowersPath(iri): + // The request is for followers of one of + // our accounts, which we can shortcut. + return t.controller.dereferenceLocalFollowers(ctx, iri) + + case uris.IsUserPath(iri): + // The request is for one of our + // accounts, which we can shortcut. return t.controller.dereferenceLocalUser(ctx, iri) + + case uris.IsAcceptsPath(iri): + // The request is for an Accept on + // our instance, which we can shortcut. + return t.controller.dereferenceLocalAccept(ctx, iri) } } diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index cb3e320d9..2946c8d09 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -393,13 +393,23 @@ func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusab return nil, gtserror.SetMalformed(err) } - // Advanced visibility toggles for this status. - // - // TODO: a lot of work to be done here -- a new type - // needs to be created for this in go-fed/activity. - // Until this is implemented, assume all true. + // Status was sent to us or dereffed + // by us so it must be federated. status.Federated = util.Ptr(true) + // Derive interaction policy for this status. + status.InteractionPolicy = ap.ExtractInteractionPolicy( + statusable, + status.Account, + ) + + // Set approvedByURI if present, + // for later dereferencing. + approvedByURI := ap.GetApprovedBy(statusable) + if approvedByURI != nil { + status.ApprovedByURI = approvedByURI.String() + } + // status.Sensitive sensitive := ap.ExtractSensitive(statusable) status.Sensitive = &sensitive diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 567493673..31b256b6c 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -36,6 +36,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // AccountToAS converts a gts model account into an activity streams person, suitable for federation @@ -672,6 +673,38 @@ func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Stat sensitiveProp.AppendXMLSchemaBoolean(*s.Sensitive) status.SetActivityStreamsSensitive(sensitiveProp) + // interactionPolicy + var p *gtsmodel.InteractionPolicy + if s.InteractionPolicy != nil { + // Use InteractionPolicy + // set on the status. + p = s.InteractionPolicy + } else { + // Fall back to default policy + // for the status's visibility. + p = gtsmodel.DefaultInteractionPolicyFor(s.Visibility) + } + policy, err := c.InteractionPolicyToASInteractionPolicy(ctx, p, s) + if err != nil { + return nil, fmt.Errorf("error creating interactionPolicy: %w", err) + } + + policyProp := streams.NewGoToSocialInteractionPolicyProperty() + policyProp.AppendGoToSocialInteractionPolicy(policy) + status.SetGoToSocialInteractionPolicy(policyProp) + + // Parse + set approvedBy. + if s.ApprovedByURI != "" { + approvedBy, err := url.Parse(s.ApprovedByURI) + if err != nil { + return nil, fmt.Errorf("error parsing approvedBy: %w", err) + } + + approvedByProp := streams.NewGoToSocialApprovedByProperty() + approvedByProp.Set(approvedBy) + status.SetGoToSocialApprovedBy(approvedByProp) + } + return status, nil } @@ -1169,6 +1202,18 @@ func (c *Converter) FaveToAS(ctx context.Context, f *gtsmodel.StatusFave) (vocab toProp.AppendIRI(toIRI) like.SetActivityStreamsTo(toProp) + // Parse + set approvedBy. + if f.ApprovedByURI != "" { + approvedBy, err := url.Parse(f.ApprovedByURI) + if err != nil { + return nil, fmt.Errorf("error parsing approvedBy: %w", err) + } + + approvedByProp := streams.NewGoToSocialApprovedByProperty() + approvedByProp.Set(approvedBy) + like.SetGoToSocialApprovedBy(approvedByProp) + } + return like, nil } @@ -1247,6 +1292,18 @@ func (c *Converter) BoostToAS(ctx context.Context, boostWrapperStatus *gtsmodel. announce.SetActivityStreamsCc(ccProp) + // Parse + set approvedBy. + if boostWrapperStatus.ApprovedByURI != "" { + approvedBy, err := url.Parse(boostWrapperStatus.ApprovedByURI) + if err != nil { + return nil, fmt.Errorf("error parsing approvedBy: %w", err) + } + + approvedByProp := streams.NewGoToSocialApprovedByProperty() + approvedByProp.Set(approvedBy) + announce.SetGoToSocialApprovedBy(approvedByProp) + } + return announce, nil } @@ -1724,3 +1781,227 @@ func (c *Converter) PollVoteToASCreate( return create, nil } + +// populateValuesForProp appends the given PolicyValues +// to the given property, for the given status. +func populateValuesForProp[T ap.WithIRI]( + prop ap.Property[T], + status *gtsmodel.Status, + urns gtsmodel.PolicyValues, +) error { + iriStrs := make([]string, 0) + + for _, urn := range urns { + switch urn { + + case gtsmodel.PolicyValueAuthor: + iriStrs = append(iriStrs, status.Account.URI) + + case gtsmodel.PolicyValueMentioned: + for _, m := range status.Mentions { + iriStrs = append(iriStrs, m.TargetAccount.URI) + } + + case gtsmodel.PolicyValueFollowing: + iriStrs = append(iriStrs, status.Account.FollowingURI) + + case gtsmodel.PolicyValueFollowers: + iriStrs = append(iriStrs, status.Account.FollowersURI) + + case gtsmodel.PolicyValuePublic: + iriStrs = append(iriStrs, pub.PublicActivityPubIRI) + + default: + iriStrs = append(iriStrs, string(urn)) + } + } + + // Deduplicate the iri strings to + // make sure we're not parsing + adding + // the same string multiple times. + iriStrs = util.Deduplicate(iriStrs) + + // Append them to the property. + for _, iriStr := range iriStrs { + iri, err := url.Parse(iriStr) + if err != nil { + return err + } + + prop.AppendIRI(iri) + } + + return nil +} + +// InteractionPolicyToASInteractionPolicy returns a +// GoToSocial interaction policy suitable for federation. +func (c *Converter) InteractionPolicyToASInteractionPolicy( + ctx context.Context, + interactionPolicy *gtsmodel.InteractionPolicy, + status *gtsmodel.Status, +) (vocab.GoToSocialInteractionPolicy, error) { + policy := streams.NewGoToSocialInteractionPolicy() + + /* + CAN LIKE + */ + + // Build canLike + canLike := streams.NewGoToSocialCanLike() + + // Build canLike.always + canLikeAlwaysProp := streams.NewGoToSocialAlwaysProperty() + if err := populateValuesForProp( + canLikeAlwaysProp, + status, + interactionPolicy.CanLike.Always, + ); err != nil { + return nil, gtserror.Newf("error setting canLike.always: %w", err) + } + + // Set canLike.always + canLike.SetGoToSocialAlways(canLikeAlwaysProp) + + // Build canLike.approvalRequired + canLikeApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + if err := populateValuesForProp( + canLikeApprovalRequiredProp, + status, + interactionPolicy.CanLike.WithApproval, + ); err != nil { + return nil, gtserror.Newf("error setting canLike.approvalRequired: %w", err) + } + + // Set canLike.approvalRequired. + canLike.SetGoToSocialApprovalRequired(canLikeApprovalRequiredProp) + + // Set canLike on the policy. + canLikeProp := streams.NewGoToSocialCanLikeProperty() + canLikeProp.AppendGoToSocialCanLike(canLike) + policy.SetGoToSocialCanLike(canLikeProp) + + /* + CAN REPLY + */ + + // Build canReply + canReply := streams.NewGoToSocialCanReply() + + // Build canReply.always + canReplyAlwaysProp := streams.NewGoToSocialAlwaysProperty() + if err := populateValuesForProp( + canReplyAlwaysProp, + status, + interactionPolicy.CanReply.Always, + ); err != nil { + return nil, gtserror.Newf("error setting canReply.always: %w", err) + } + + // Set canReply.always + canReply.SetGoToSocialAlways(canReplyAlwaysProp) + + // Build canReply.approvalRequired + canReplyApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + if err := populateValuesForProp( + canReplyApprovalRequiredProp, + status, + interactionPolicy.CanReply.WithApproval, + ); err != nil { + return nil, gtserror.Newf("error setting canReply.approvalRequired: %w", err) + } + + // Set canReply.approvalRequired. + canReply.SetGoToSocialApprovalRequired(canReplyApprovalRequiredProp) + + // Set canReply on the policy. + canReplyProp := streams.NewGoToSocialCanReplyProperty() + canReplyProp.AppendGoToSocialCanReply(canReply) + policy.SetGoToSocialCanReply(canReplyProp) + + /* + CAN ANNOUNCE + */ + + // Build canAnnounce + canAnnounce := streams.NewGoToSocialCanAnnounce() + + // Build canAnnounce.always + canAnnounceAlwaysProp := streams.NewGoToSocialAlwaysProperty() + if err := populateValuesForProp( + canAnnounceAlwaysProp, + status, + interactionPolicy.CanAnnounce.Always, + ); err != nil { + return nil, gtserror.Newf("error setting canAnnounce.always: %w", err) + } + + // Set canAnnounce.always + canAnnounce.SetGoToSocialAlways(canAnnounceAlwaysProp) + + // Build canAnnounce.approvalRequired + canAnnounceApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + if err := populateValuesForProp( + canAnnounceApprovalRequiredProp, + status, + interactionPolicy.CanAnnounce.WithApproval, + ); err != nil { + return nil, gtserror.Newf("error setting canAnnounce.approvalRequired: %w", err) + } + + // Set canAnnounce.approvalRequired. + canAnnounce.SetGoToSocialApprovalRequired(canAnnounceApprovalRequiredProp) + + // Set canAnnounce on the policy. + canAnnounceProp := streams.NewGoToSocialCanAnnounceProperty() + canAnnounceProp.AppendGoToSocialCanAnnounce(canAnnounce) + policy.SetGoToSocialCanAnnounce(canAnnounceProp) + + return policy, nil +} + +// InteractionApprovalToASAccept converts a *gtsmodel.InteractionApproval +// to an ActivityStreams Accept, addressed to the interacting account. +func (c *Converter) InteractionApprovalToASAccept( + ctx context.Context, + approval *gtsmodel.InteractionApproval, +) (vocab.ActivityStreamsAccept, error) { + accept := streams.NewActivityStreamsAccept() + + acceptID, err := url.Parse(approval.URI) + if err != nil { + return nil, gtserror.Newf("invalid accept uri: %w", err) + } + + actorIRI, err := url.Parse(approval.Account.URI) + if err != nil { + return nil, gtserror.Newf("invalid account uri: %w", err) + } + + objectIRI, err := url.Parse(approval.InteractionURI) + if err != nil { + return nil, gtserror.Newf("invalid target uri: %w", err) + } + + toIRI, err := url.Parse(approval.InteractingAccount.URI) + if err != nil { + return nil, gtserror.Newf("invalid interacting account uri: %w", err) + } + + // Set id to the URI of + // interactionApproval. + ap.SetJSONLDId(accept, acceptID) + + // Actor is the account that + // owns the approval / accept. + ap.AppendActorIRIs(accept, actorIRI) + + // Object is the interaction URI. + ap.AppendObjectIRIs(accept, objectIRI) + + // Address to the owner + // of interaction URI. + ap.AppendTo(accept, toIRI) + + return accept, nil +} diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index ca8143436..905dccfad 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -21,8 +21,6 @@ import ( "context" "encoding/json" "errors" - "fmt" - "strings" "testing" "github.com/stretchr/testify/suite" @@ -46,14 +44,15 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'discoverable'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - - suite.Equal(`: true, + suite.Equal(`{ + "discoverable": true, "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", @@ -82,7 +81,7 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { "tag": [], "type": "Person", "url": "http://localhost:8080/@the_mighty_zork" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASWithFields() { @@ -95,16 +94,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'attachment'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"attachment\"")[1] - - fmt.Printf("\n\n\n%s\n\n\n", string(bytes)) - - suite.Equal(`: [ + suite.Equal(`{ + "attachment": [ { "name": "should you follow me?", "type": "PropertyValue", @@ -135,7 +133,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() { "tag": [], "type": "Person", "url": "http://localhost:8080/@1happyturtle" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { @@ -161,14 +159,15 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'alsoKnownAs'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"alsoKnownAs\"")[1] - - suite.Equal(`: [ + suite.Equal(`{ + "alsoKnownAs": [ "http://localhost:8080/users/1happyturtle" ], "discoverable": true, @@ -201,7 +200,7 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { "tag": [], "type": "Person", "url": "http://localhost:8080/@the_mighty_zork" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { @@ -215,15 +214,16 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'attachment'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"attachment\"")[1] - // Despite only one field being set, attachments should still be a slice/array. - suite.Equal(`: [ + suite.Equal(`{ + "attachment": [ { "name": "should you follow me?", "type": "PropertyValue", @@ -249,7 +249,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { "tag": [], "type": "Person", "url": "http://localhost:8080/@1happyturtle" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { @@ -263,14 +263,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'discoverable'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - - suite.Equal(`: true, + suite.Equal(`{ + "discoverable": true, "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", @@ -309,7 +310,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { }, "type": "Person", "url": "http://localhost:8080/@the_mighty_zork" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { @@ -324,14 +325,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'discoverable'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - - suite.Equal(`: true, + suite.Equal(`{ + "discoverable": true, "endpoints": { "sharedInbox": "http://localhost:8080/sharedInbox" }, @@ -363,7 +365,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { "tag": [], "type": "Person", "url": "http://localhost:8080/@the_mighty_zork" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestStatusToAS() { @@ -376,11 +378,14 @@ func (suite *InternalToASTestSuite) TestStatusToAS() { ser, err := ap.Serialize(asStatus) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) suite.Equal(`{ - "@context": "https://www.w3.org/ns/activitystreams", "attachment": [], "attributedTo": "http://localhost:8080/users/the_mighty_zork", "cc": "http://localhost:8080/users/the_mighty_zork/followers", @@ -389,6 +394,26 @@ func (suite *InternalToASTestSuite) TestStatusToAS() { "en": "hello everyone!" }, "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-10-20T12:40:37+02:00", "replies": { "first": { @@ -420,14 +445,15 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() { ser, err := ap.Serialize(asStatus) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // we can't be sure in what order the two context entries -- - // http://joinmastodon.org/ns, https://www.w3.org/ns/activitystreams -- - // will appear, so trim them out of the string for consistency - trimmed := strings.SplitAfter(string(bytes), `"attachment":`)[1] - suite.Equal(` [ + suite.Equal(`{ + "attachment": [ { "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj", "mediaType": "image/jpeg", @@ -443,6 +469,26 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() { "en": "hello world! #welcome ! first post on the instance :rainbow: !" }, "id": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-10-20T11:36:45Z", "replies": { "first": { @@ -477,7 +523,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() { "to": "https://www.w3.org/ns/activitystreams#Public", "type": "Note", "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { @@ -492,14 +538,15 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { ser, err := ap.Serialize(asStatus) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // we can't be sure in what order the two context entries -- - // http://joinmastodon.org/ns, https://www.w3.org/ns/activitystreams -- - // will appear, so trim them out of the string for consistency - trimmed := strings.SplitAfter(string(bytes), `"attachment":`)[1] - suite.Equal(` [ + suite.Equal(`{ + "attachment": [ { "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj", "mediaType": "image/jpeg", @@ -515,6 +562,26 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { "en": "hello world! #welcome ! first post on the instance :rainbow: !" }, "id": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-10-20T11:36:45Z", "replies": { "first": { @@ -549,7 +616,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { "to": "https://www.w3.org/ns/activitystreams#Public", "type": "Note", "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestStatusToASWithMentions() { @@ -565,11 +632,14 @@ func (suite *InternalToASTestSuite) TestStatusToASWithMentions() { ser, err := ap.Serialize(asStatus) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) suite.Equal(`{ - "@context": "https://www.w3.org/ns/activitystreams", "attachment": [], "attributedTo": "http://localhost:8080/users/admin", "cc": [ @@ -582,6 +652,26 @@ func (suite *InternalToASTestSuite) TestStatusToASWithMentions() { }, "id": "http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0", "inReplyTo": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-11-20T13:32:16Z", "replies": { "first": { @@ -967,6 +1057,51 @@ func (suite *InternalToASTestSuite) TestPollVoteToASCreate() { }`, string(bytes)) } +func (suite *InternalToASTestSuite) TestInteractionApprovalToASAccept() { + acceptingAccount := suite.testAccounts["local_account_1"] + interactingAccount := suite.testAccounts["remote_account_1"] + + interactionApproval := >smodel.InteractionApproval{ + ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE", + CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"), + UpdatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"), + AccountID: acceptingAccount.ID, + Account: acceptingAccount, + InteractingAccountID: interactingAccount.ID, + InteractingAccount: interactingAccount, + InteractionURI: "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K", + InteractionType: gtsmodel.InteractionAnnounce, + URI: "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE", + } + + accept, err := suite.typeconverter.InteractionApprovalToASAccept( + context.Background(), + interactionApproval, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + i, err := ap.Serialize(accept) + if err != nil { + suite.FailNow(err.Error()) + } + + b, err := json.MarshalIndent(i, "", " ") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(`{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "http://localhost:8080/users/the_mighty_zork", + "id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE", + "object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K", + "to": "http://fossbros-anonymous.io/users/foss_satan", + "type": "Accept" +}`, string(b)) +} + func TestInternalToASTestSuite(t *testing.T) { suite.Run(t, new(InternalToASTestSuite)) } diff --git a/internal/typeutils/wrap_test.go b/internal/typeutils/wrap_test.go index 453073ed6..833b18bac 100644 --- a/internal/typeutils/wrap_test.go +++ b/internal/typeutils/wrap_test.go @@ -72,11 +72,14 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() { createI, err := ap.Serialize(create) suite.NoError(err) + // Chop off @context since + // ordering is non-determinate. + delete(createI, "@context") + bytes, err := json.MarshalIndent(createI, "", " ") suite.NoError(err) suite.Equal(`{ - "@context": "https://www.w3.org/ns/activitystreams", "actor": "http://localhost:8080/users/the_mighty_zork", "cc": "http://localhost:8080/users/the_mighty_zork/followers", "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/activity#Create", @@ -89,6 +92,26 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() { "en": "hello everyone!" }, "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-10-20T12:40:37+02:00", "replies": { "first": { diff --git a/internal/uris/uri.go b/internal/uris/uri.go index 335461d84..159508176 100644 --- a/internal/uris/uri.go +++ b/internal/uris/uri.go @@ -46,6 +46,7 @@ const ( FileserverPath = "fileserver" // FileserverPath is a path component for serving attachments + media EmojiPath = "emoji" // EmojiPath represents the activitypub emoji location TagsPath = "tags" // TagsPath represents the activitypub tags location + AcceptsPath = "accepts" // AcceptsPath represents the activitypub accepts location ) // UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc. @@ -136,6 +137,14 @@ func GenerateURIForEmailConfirm(token string) string { return fmt.Sprintf("%s://%s/%s?token=%s", protocol, host, ConfirmEmailPath, token) } +// GenerateURIForAccept returns the AP URI for a new accept activity -- something like: +// https://example.org/users/whatever_user/accepts/01F7XTH1QGBAPMGF49WJZ91XGC +func GenerateURIForAccept(username string, thisAcceptID string) string { + protocol := config.GetProtocol() + host := config.GetHost() + return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, AcceptsPath, thisAcceptID) +} + // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. func GenerateURIsForAccount(username string) *UserURIs { protocol := config.GetProtocol() @@ -317,6 +326,11 @@ func IsReportPath(id *url.URL) bool { return regexes.ReportPath.MatchString(id.Path) } +// IsAcceptsPath returns true if the given URL path corresponds to eg /users/example_username/accepts/SOME_ULID_OF_AN_ACCEPT +func IsAcceptsPath(id *url.URL) bool { + return regexes.AcceptsPath.MatchString(id.Path) +} + // ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { matches := regexes.StatusesPath.FindStringSubmatch(id.Path) diff --git a/vendor/github.com/superseriousbusiness/activity/streams/impl/activitystreams/type_announce/gen_pkg.go b/vendor/github.com/superseriousbusiness/activity/streams/impl/activitystreams/type_announce/gen_pkg.go index 5302ba8fc..7ee4ff56e 100644 --- a/vendor/github.com/superseriousbusiness/activity/streams/impl/activitystreams/type_announce/gen_pkg.go +++ b/vendor/github.com/superseriousbusiness/activity/streams/impl/activitystreams/type_announce/gen_pkg.go @@ -19,6 +19,10 @@ type privateManager interface { // method for the "ActivityStreamsAltitudeProperty" non-functional // property in the vocabulary "ActivityStreams" DeserializeAltitudePropertyActivityStreams() func(map[string]interface{}, map[string]string) (vocab.ActivityStreamsAltitudeProperty, error) + // DeserializeApprovedByPropertyGoToSocial returns the deserialization + // method for the "GoToSocialApprovedByProperty" non-functional + // property in the vocabulary "GoToSocial" + DeserializeApprovedByPropertyGoToSocial() func(map[string]interface{}, map[string]string) (vocab.GoToSocialApprovedByProperty, error) // DeserializeAttachmentPropertyActivityStreams returns the // deserialization method for the "ActivityStreamsAttachmentProperty" // non-functional property in the vocabulary "ActivityStreams" diff --git a/vendor/github.com/superseriousbusiness/activity/streams/impl/activitystreams/type_announce/gen_type_activitystreams_announce.go b/vendor/github.com/superseriousbusiness/activity/streams/impl/activitystreams/type_announce/gen_type_activitystreams_announce.go index db4c523e7..af597cb76 100644 --- a/vendor/github.com/superseriousbusiness/activity/streams/impl/activitystreams/type_announce/gen_type_activitystreams_announce.go +++ b/vendor/github.com/superseriousbusiness/activity/streams/impl/activitystreams/type_announce/gen_type_activitystreams_announce.go @@ -32,6 +32,7 @@ import ( type ActivityStreamsAnnounce struct { ActivityStreamsActor vocab.ActivityStreamsActorProperty ActivityStreamsAltitude vocab.ActivityStreamsAltitudeProperty + GoToSocialApprovedBy vocab.GoToSocialApprovedByProperty ActivityStreamsAttachment vocab.ActivityStreamsAttachmentProperty ActivityStreamsAttributedTo vocab.ActivityStreamsAttributedToProperty ActivityStreamsAudience vocab.ActivityStreamsAudienceProperty @@ -152,6 +153,11 @@ func DeserializeAnnounce(m map[string]interface{}, aliasMap map[string]string) ( } else if p != nil { this.ActivityStreamsAltitude = p } + if p, err := mgr.DeserializeApprovedByPropertyGoToSocial()(m, aliasMap); err != nil { + return nil, err + } else if p != nil { + this.GoToSocialApprovedBy = p + } if p, err := mgr.DeserializeAttachmentPropertyActivityStreams()(m, aliasMap); err != nil { return nil, err } else if p != nil { @@ -346,6 +352,8 @@ func DeserializeAnnounce(m map[string]interface{}, aliasMap map[string]string) ( continue } else if k == "altitude" { continue + } else if k == "approvedBy" { + continue } else if k == "attachment" { continue } else if k == "attributedTo" { @@ -675,6 +683,12 @@ func (this ActivityStreamsAnnounce) GetActivityStreamsUrl() vocab.ActivityStream return this.ActivityStreamsUrl } +// GetGoToSocialApprovedBy returns the "approvedBy" property if it exists, and nil +// otherwise. +func (this ActivityStreamsAnnounce) GetGoToSocialApprovedBy() vocab.GoToSocialApprovedByProperty { + return this.GoToSocialApprovedBy +} + // GetJSONLDId returns the "id" property if it exists, and nil otherwise. func (this ActivityStreamsAnnounce) GetJSONLDId() vocab.JSONLDIdProperty { return this.JSONLDId @@ -712,6 +726,7 @@ func (this ActivityStreamsAnnounce) JSONLDContext() map[string]string { m := map[string]string{"https://www.w3.org/ns/activitystreams": this.alias} m = this.helperJSONLDContext(this.ActivityStreamsActor, m) m = this.helperJSONLDContext(this.ActivityStreamsAltitude, m) + m = this.helperJSONLDContext(this.GoToSocialApprovedBy, m) m = this.helperJSONLDContext(this.ActivityStreamsAttachment, m) m = this.helperJSONLDContext(this.ActivityStreamsAttributedTo, m) m = this.helperJSONLDContext(this.ActivityStreamsAudience, m) @@ -785,6 +800,20 @@ func (this ActivityStreamsAnnounce) LessThan(o vocab.ActivityStreamsAnnounce) bo // Anything else is greater than nil return false } // Else: Both are nil + // Compare property "approvedBy" + if lhs, rhs := this.GoToSocialApprovedBy, o.GetGoToSocialApprovedBy(); lhs != nil && rhs != nil { + if lhs.LessThan(rhs) { + return true + } else if rhs.LessThan(lhs) { + return false + } + } else if lhs == nil && rhs != nil { + // Nil is less than anything else + return true + } else if rhs != nil && rhs == nil { + // Anything else is greater than nil + return false + } // Else: Both are nil // Compare property "attachment" if lhs, rhs := this.ActivityStreamsAttachment, o.GetActivityStreamsAttachment(); lhs != nil && rhs != nil { if lhs.LessThan(rhs) { @@ -1342,6 +1371,14 @@ func (this ActivityStreamsAnnounce) Serialize() (map[string]interface{}, error) m[this.ActivityStreamsAltitude.Name()] = i } } + // Maybe serialize property "approvedBy" + if this.GoToSocialApprovedBy != nil { + if i, err := this.GoToSocialApprovedBy.Serialize(); err != nil { + return nil, err + } else if i != nil { + m[this.GoToSocialApprovedBy.Name()] = i + } + } // Maybe serialize property "attachment" if this.ActivityStreamsAttachment != nil { if i, err := this.ActivityStreamsAttachment.Serialize(); err != nil { @@ -1837,6 +1874,11 @@ func (this *ActivityStreamsAnnounce) SetActivityStreamsUrl(i vocab.ActivityStrea this.ActivityStreamsUrl = i } +// SetGoToSocialApprovedBy sets the "approvedBy" property. +func (this *ActivityStreamsAnnounce) SetGoToSocialApprovedBy(i vocab.GoToSocialApprovedByProperty) { + this.GoToSocialApprovedBy = i +} + // SetJSONLDId sets the "id" property. func (this *ActivityStreamsAnnounce) SetJSONLDId(i vocab.JSONLDIdProperty) { this.JSONLDId = i diff --git a/vendor/github.com/superseriousbusiness/activity/streams/vocab/gen_type_activitystreams_announce_interface.go b/vendor/github.com/superseriousbusiness/activity/streams/vocab/gen_type_activitystreams_announce_interface.go index aecd4e8e6..c224bb3ca 100644 --- a/vendor/github.com/superseriousbusiness/activity/streams/vocab/gen_type_activitystreams_announce_interface.go +++ b/vendor/github.com/superseriousbusiness/activity/streams/vocab/gen_type_activitystreams_announce_interface.go @@ -135,6 +135,9 @@ type ActivityStreamsAnnounce interface { // GetActivityStreamsUrl returns the "url" property if it exists, and nil // otherwise. GetActivityStreamsUrl() ActivityStreamsUrlProperty + // GetGoToSocialApprovedBy returns the "approvedBy" property if it exists, + // and nil otherwise. + GetGoToSocialApprovedBy() GoToSocialApprovedByProperty // GetJSONLDId returns the "id" property if it exists, and nil otherwise. GetJSONLDId() JSONLDIdProperty // GetJSONLDType returns the "type" property if it exists, and nil @@ -237,6 +240,8 @@ type ActivityStreamsAnnounce interface { SetActivityStreamsUpdated(i ActivityStreamsUpdatedProperty) // SetActivityStreamsUrl sets the "url" property. SetActivityStreamsUrl(i ActivityStreamsUrlProperty) + // SetGoToSocialApprovedBy sets the "approvedBy" property. + SetGoToSocialApprovedBy(i GoToSocialApprovedByProperty) // SetJSONLDId sets the "id" property. SetJSONLDId(i JSONLDIdProperty) // SetJSONLDType sets the "type" property. diff --git a/vendor/modules.txt b/vendor/modules.txt index 6eeae4e54..a2ffe58bc 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -602,7 +602,7 @@ github.com/stretchr/testify/suite # github.com/subosito/gotenv v1.6.0 ## explicit; go 1.18 github.com/subosito/gotenv -# github.com/superseriousbusiness/activity v1.7.0-gts +# github.com/superseriousbusiness/activity v1.8.0-gts ## explicit; go 1.18 github.com/superseriousbusiness/activity/pub github.com/superseriousbusiness/activity/streams