[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
This commit is contained in:
tobi 2024-07-26 12:04:28 +02:00 committed by GitHub
parent f8d399cf6a
commit 8ab2b19a94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 3541 additions and 254 deletions

View File

@ -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.

2
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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()
}

View File

@ -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 &gtsmodel.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.

View File

@ -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 <http://www.gnu.org/licenses/>.
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 := &gtsmodel.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{})
}

View File

@ -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)
}

View File

@ -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.
//

View File

@ -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.
//

View File

@ -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.

View File

@ -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
}

View File

@ -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 <http://www.gnu.org/licenses/>.
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)
}

View File

@ -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)
}

View File

@ -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 <http://www.gnu.org/licenses/>.
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(&gtsmodel.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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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())
}

View File

@ -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.
}

View File

@ -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.
}

View File

@ -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 <http://www.gnu.org/licenses/>.
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
}

View File

@ -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,

View File

@ -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.

View File

@ -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.

View File

@ -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,
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)

View File

@ -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 {

View File

@ -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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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
}

View File

@ -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

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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 := &gtsmodel.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))
}

View File

@ -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": {

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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.

2
vendor/modules.txt vendored
View File

@ -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