Serve `outbox` for Actor (#289)

* add statusesvisible convenience function

* add minID + onlyPublic to account statuses get

* move swagger collection stuff to common

* start working on Outbox GETting

* move functions into federationProcessor

* outboxToASCollection

* add statusesvisible convenience function

* add minID + onlyPublic to account statuses get

* move swagger collection stuff to common

* start working on Outbox GETting

* move functions into federationProcessor

* outboxToASCollection

* bit more work on outbox paging

* wrapNoteInCreate function

* test + hook up the processor functions

* don't do prev + next links on empty reply

* test get outbox through api

* don't fail on no status entries

* add outbox implementation doc

* typo
This commit is contained in:
tobi 2021-10-24 11:57:39 +02:00 committed by GitHub
parent 26a95ad27d
commit 4b1d9d3780
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1851 additions and 470 deletions

View File

@ -0,0 +1,47 @@
# ActivityPub Outbox
GoToSocial implements Outboxes for Actors (ie., instance accounts) following the ActivityPub specification [here](https://www.w3.org/TR/activitypub/#outbox).
To get an [OrderedCollection](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection) of Activities that an Actor has published recently, remote servers can do a `GET` request to a user's outbox. The address of this will be something like `https://example.org/users/whatever/outbox`.
The server will return an OrderedCollection of the following structure:
```json
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.org/users/whatever/outbox",
"type": "OrderedCollection",
"first": "https://example.org/users/whatever/outbox?page=true"
}
```
Note that the `OrderedCollection` itself contains no items. Callers must dereference the `first` page to start getting items. For example, a `GET` to `https://example.org/users/whatever/outbox?page=true` will produce something like the following:
```json
{
"id": "https://example.org/users/whatever/outbox?page=true",
"type": "OrderedCollectionPage",
"next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
"prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
"partOf": "https://example.org/users/whatever/outbox",
"orderedItems": [
"id": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7/activity",
"type": "Create",
"actor": "https://example.org/users/whatever",
"published": "2021-10-18T20:06:18Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.org/users/whatever/followers"
],
"object": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7"
]
}
```
The `orderedItems` array will contain up to 30 entries. To get more entries beyond that, the caller can use the `next` link provided in the response.
Note that in the returned `orderedItems`, all activity types will be `Create`. On each activity, the `object` field will be the AP URI of an original public status created by the Actor who owns the Outbox (ie., a `Note` with `https://www.w3.org/ns/activitystreams#Public` in the `to` field, which is not a reply to another status). Callers can use the returned AP URIs to dereference the content of the notes.
Contrary to the ActivityPub spec, GoToSocial will deny requests that are not HTTP signed--that is, unauthenticated requests. This is consistent with GoToSocial's authentication policies for other federation API endpoints. This is to ensure that GoToSocial can deny requests from domains or users that have been blocked either by the GoToSocial instance itself (domain block), or by the individual owner of the Outbox.

View File

@ -39,8 +39,12 @@ const (
PinnedKey = "pinned"
// MaxIDKey is for specifying the maximum ID of the status to retrieve.
MaxIDKey = "max_id"
// MediaOnlyKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account.
MediaOnlyKey = "only_media"
// MinIDKey is for specifying the minimum ID of the status to retrieve.
MinIDKey = "min_id"
// OnlyMediaKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account.
OnlyMediaKey = "only_media"
// OnlyPublicKey is for specifying that only statuses with visibility public should be returned in a list of returned statuses by account.
OnlyPublicKey = "only_public"
// IDKey is the key to use for retrieving account ID in requests
IDKey = "id"

View File

@ -64,6 +64,12 @@ import (
// Return only statuses *OLDER* than the given max status ID.
// The status with the specified ID will not be included in the response.
// in: query
// - name: min_id
// type: string
// description: |-
// Return only statuses *NEWER* than the given min status ID.
// The status with the specified ID will not be included in the response.
// in: query
// required: false
// - name: pinned_only
// type: boolean
@ -71,12 +77,18 @@ import (
// default: false
// in: query
// required: false
// - name: media_only
// - name: only_media
// type: boolean
// description: Show only statuses with media attachments.
// default: false
// in: query
// required: false
// - name: only_public
// type: boolean
// description: Show only statuses with a privacy setting of 'public'.
// default: false
// in: query
// required: false
//
// security:
// - OAuth2 Bearer:
@ -143,6 +155,12 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
maxID = maxIDString
}
minID := ""
minIDString := c.Query(MinIDKey)
if minIDString != "" {
minID = minIDString
}
pinnedOnly := false
pinnedString := c.Query(PinnedKey)
if pinnedString != "" {
@ -156,7 +174,7 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
}
mediaOnly := false
mediaOnlyString := c.Query(MediaOnlyKey)
mediaOnlyString := c.Query(OnlyMediaKey)
if mediaOnlyString != "" {
i, err := strconv.ParseBool(mediaOnlyString)
if err != nil {
@ -167,7 +185,19 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
mediaOnly = i
}
statuses, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly)
publicOnly := false
publicOnlyString := c.Query(OnlyPublicKey)
if mediaOnlyString != "" {
i, err := strconv.ParseBool(publicOnlyString)
if err != nil {
l.Debugf("error parsing public only string: %s", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse public only query param"})
return
}
mediaOnly = i
}
statuses, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
if errWithCode != nil {
l.Debugf("error from processor account statuses get: %s", errWithCode)
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})

View File

@ -57,3 +57,41 @@ func negotiateFormat(c *gin.Context) (string, error) {
}
return format, nil
}
// SwaggerCollection represents an activitypub collection.
// swagger:model swaggerCollection
type SwaggerCollection struct {
// ActivityStreams context.
// example: https://www.w3.org/ns/activitystreams
Context string `json:"@context"`
// ActivityStreams ID.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
ID string `json:"id"`
// ActivityStreams type.
// example: Collection
Type string `json:"type"`
// ActivityStreams first property.
First SwaggerCollectionPage `json:"first"`
// ActivityStreams last property.
Last SwaggerCollectionPage `json:"last,omitempty"`
}
// SwaggerCollectionPage represents one page of a collection.
// swagger:model swaggerCollectionPage
type SwaggerCollectionPage struct {
// ActivityStreams ID.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies?page=true
ID string `json:"id"`
// ActivityStreams type.
// example: CollectionPage
Type string `json:"type"`
// Link to the next page.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies?only_other_accounts=true&page=true
Next string `json:"next"`
// Collection this page belongs to.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
PartOf string `json:"partOf"`
// Items on this page.
// example: ["https://example.org/users/some_other_user/statuses/086417595981111564", "https://another.example.com/users/another_user/statuses/01FCN8XDV3YG7B4R42QA6YQZ9R"]
Items []string `json:"items"`
}

View File

@ -436,7 +436,7 @@ func (suite *InboxPostTestSuite) TestPostDelete() {
suite.ErrorIs(err, db.ErrNoEntries)
// no statuses from foss satan should be left in the database
dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", false, false)
dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", "", false, false, false)
suite.ErrorIs(err, db.ErrNoEntries)
suite.Empty(dbStatuses)

View File

@ -0,0 +1,142 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 user
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// OutboxGETHandler swagger:operation GET /users/{username}/outbox s2sOutboxGet
//
// Get the public outbox collection for an actor.
//
// Note that the response will be a Collection with a page as `first`, as shown below, if `page` is `false`.
//
// If `page` is `true`, then the response will be a single `CollectionPage` without the wrapping `Collection`.
//
// HTTP signature is required on the request.
//
// ---
// tags:
// - s2s/federation
//
// produces:
// - application/activity+json
//
// parameters:
// - name: username
// type: string
// description: Username of the account.
// in: path
// required: true
// - name: page
// type: boolean
// description: Return response as a CollectionPage.
// in: query
// default: false
// - name: min_id
// type: string
// description: Minimum ID of the next status, used for paging.
// in: query
// - name: max_id
// type: string
// description: Maximum ID of the next status, used for paging.
// in: query
//
// responses:
// '200':
// in: body
// schema:
// "$ref": "#/definitions/swaggerCollection"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
func (m *Module) OutboxGETHandler(c *gin.Context) {
l := logrus.WithFields(logrus.Fields{
"func": "OutboxGETHandler",
"url": c.Request.RequestURI,
})
requestedUsername := c.Param(UsernameKey)
if requestedUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"})
return
}
page := false
pageString := c.Query(PageKey)
if pageString != "" {
i, err := strconv.ParseBool(pageString)
if err != nil {
l.Debugf("error parsing page string: %s", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse page query param"})
return
}
page = i
}
minID := ""
minIDString := c.Query(MinIDKey)
if minIDString != "" {
minID = minIDString
}
maxID := ""
maxIDString := c.Query(MaxIDKey)
if maxIDString != "" {
maxID = maxIDString
}
format, err := negotiateFormat(c)
if err != nil {
c.JSON(http.StatusNotAcceptable, gin.H{"error": fmt.Sprintf("could not negotiate format with given Accept header(s): %s", err)})
return
}
l.Tracef("negotiated format: %s", format)
ctx := transferContext(c)
outbox, errWithCode := m.processor.GetFediOutbox(ctx, requestedUsername, page, maxID, minID, c.Request.URL)
if errWithCode != nil {
l.Info(errWithCode.Error())
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
return
}
b, mErr := json.Marshal(outbox)
if mErr != nil {
err := fmt.Errorf("could not marshal json: %s", mErr)
l.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, format, b)
}

View File

@ -0,0 +1,206 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 user_test
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type OutboxGetTestSuite struct {
UserStandardTestSuite
}
func (suite *OutboxGetTestSuite) TestGetOutbox() {
// the dereference we're gonna use
derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts)
signedRequest := derefRequests["foss_satan_dereference_zork_outbox"]
targetAccount := suite.testAccounts["local_account_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
userModule := user.New(suite.config, processor).(*user.Module)
// setup request
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI, nil) // the endpoint we're hitting
ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
ctx.Request.Header.Set("Date", signedRequest.DateHeader)
// we need to pass the context through signature check first to set appropriate values on it
suite.securityModule.SignatureCheck(ctx)
// normally the router would populate these params from the path values,
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{
gin.Param{
Key: user.UsernameKey,
Value: targetAccount.Username,
},
}
// trigger the function being tested
userModule.OutboxGETHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
suite.NoError(err)
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","first":"http://localhost:8080/users/the_mighty_zork/outbox?page=true","id":"http://localhost:8080/users/the_mighty_zork/outbox","type":"OrderedCollection"}`, string(b))
m := make(map[string]interface{})
err = json.Unmarshal(b, &m)
suite.NoError(err)
t, err := streams.ToType(context.Background(), m)
suite.NoError(err)
_, ok := t.(vocab.ActivityStreamsOrderedCollection)
suite.True(ok)
}
func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() {
// the dereference we're gonna use
derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts)
signedRequest := derefRequests["foss_satan_dereference_zork_outbox_first"]
targetAccount := suite.testAccounts["local_account_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
userModule := user.New(suite.config, processor).(*user.Module)
// setup request
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true", nil) // the endpoint we're hitting
ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
ctx.Request.Header.Set("Date", signedRequest.DateHeader)
// we need to pass the context through signature check first to set appropriate values on it
suite.securityModule.SignatureCheck(ctx)
// normally the router would populate these params from the path values,
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{
gin.Param{
Key: user.UsernameKey,
Value: targetAccount.Username,
},
}
// trigger the function being tested
userModule.OutboxGETHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
suite.NoError(err)
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/outbox?page=true","next":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026max_id=01F8MHAMCHF6Y650WCRSCP4WMY","orderedItems":{"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","object":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY","published":"2021-10-20T10:40:37Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"},"partOf":"http://localhost:8080/users/the_mighty_zork/outbox","prev":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026min_id=01F8MHAMCHF6Y650WCRSCP4WMY","type":"OrderedCollectionPage"}`, string(b))
m := make(map[string]interface{})
err = json.Unmarshal(b, &m)
suite.NoError(err)
t, err := streams.ToType(context.Background(), m)
suite.NoError(err)
_, ok := t.(vocab.ActivityStreamsOrderedCollectionPage)
suite.True(ok)
}
func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() {
// the dereference we're gonna use
derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts)
signedRequest := derefRequests["foss_satan_dereference_zork_outbox_next"]
targetAccount := suite.testAccounts["local_account_1"]
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
userModule := user.New(suite.config, processor).(*user.Module)
// setup request
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true&max_id=01F8MHAMCHF6Y650WCRSCP4WMY", nil) // the endpoint we're hitting
ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
ctx.Request.Header.Set("Date", signedRequest.DateHeader)
// we need to pass the context through signature check first to set appropriate values on it
suite.securityModule.SignatureCheck(ctx)
// normally the router would populate these params from the path values,
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{
gin.Param{
Key: user.UsernameKey,
Value: targetAccount.Username,
},
gin.Param{
Key: user.MaxIDKey,
Value: "01F8MHAMCHF6Y650WCRSCP4WMY",
},
}
// trigger the function being tested
userModule.OutboxGETHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
suite.NoError(err)
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026maxID=01F8MHAMCHF6Y650WCRSCP4WMY","orderedItems":[],"partOf":"http://localhost:8080/users/the_mighty_zork/outbox","type":"OrderedCollectionPage"}`, string(b))
m := make(map[string]interface{})
err = json.Unmarshal(b, &m)
suite.NoError(err)
t, err := streams.ToType(context.Background(), m)
suite.NoError(err)
_, ok := t.(vocab.ActivityStreamsOrderedCollectionPage)
suite.True(ok)
}
func TestOutboxGetTestSuite(t *testing.T) {
suite.Run(t, new(OutboxGetTestSuite))
}

View File

@ -75,7 +75,7 @@ import (
// '200':
// in: body
// schema:
// "$ref": "#/definitions/swaggerStatusRepliesCollection"
// "$ref": "#/definitions/swaggerCollection"
// '400':
// description: bad request
// '401':
@ -158,39 +158,3 @@ func (m *Module) StatusRepliesGETHandler(c *gin.Context) {
c.Data(http.StatusOK, format, b)
}
// SwaggerStatusRepliesCollection represents a response to GET /users/{username}/statuses/{status}/replies.
// swagger:model swaggerStatusRepliesCollection
type SwaggerStatusRepliesCollection struct {
// ActivityStreams context.
// example: https://www.w3.org/ns/activitystreams
Context string `json:"@context"`
// ActivityStreams ID.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
ID string `json:"id"`
// ActivityStreams type.
// example: Collection
Type string `json:"type"`
// ActivityStreams first property.
First SwaggerStatusRepliesCollectionPage `json:"first"`
}
// SwaggerStatusRepliesCollectionPage represents one page of a collection.
// swagger:model swaggerStatusRepliesCollectionPage
type SwaggerStatusRepliesCollectionPage struct {
// ActivityStreams ID.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies?page=true
ID string `json:"id"`
// ActivityStreams type.
// example: CollectionPage
Type string `json:"type"`
// Link to the next page.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies?only_other_accounts=true&page=true
Next string `json:"next"`
// Collection this page belongs to.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
PartOf string `json:"partOf"`
// Items on this page.
// example: ["https://example.org/users/some_other_user/statuses/086417595981111564", "https://another.example.com/users/another_user/statuses/01FCN8XDV3YG7B4R42QA6YQZ9R"]
Items []string `json:"items"`
}

View File

@ -37,6 +37,8 @@ const (
OnlyOtherAccountsKey = "only_other_accounts"
// MinIDKey is for filtering status responses.
MinIDKey = "min_id"
// MaxIDKey is for filtering status responses.
MaxIDKey = "max_id"
// PageKey is for filtering status responses.
PageKey = "page"
@ -50,6 +52,8 @@ const (
UsersPublicKeyPath = UsersBasePathWithUsername + "/" + util.PublicKeyPath
// UsersInboxPath is for serving POST requests to a user's inbox with the given username key.
UsersInboxPath = UsersBasePathWithUsername + "/" + util.InboxPath
// UsersOutboxPath is for serving GET requests to a user's outbox with the given username key.
UsersOutboxPath = UsersBasePathWithUsername + "/" + util.OutboxPath
// UsersFollowersPath is for serving GET request's to a user's followers list, with the given username key.
UsersFollowersPath = UsersBasePathWithUsername + "/" + util.FollowersPath
// UsersFollowingPath is for serving GET request's to a user's following list, with the given username key.
@ -83,5 +87,6 @@ func (m *Module) Route(s router.Router) error {
s.AttachHandler(http.MethodGet, UsersStatusPath, m.StatusGETHandler)
s.AttachHandler(http.MethodGet, UsersPublicKeyPath, m.PublicKeyGETHandler)
s.AttachHandler(http.MethodGet, UsersStatusRepliesPath, m.StatusRepliesGETHandler)
s.AttachHandler(http.MethodGet, UsersOutboxPath, m.OutboxGETHandler)
return nil
}

View File

@ -52,7 +52,7 @@ type Account interface {
// then all statuses will be returned. If limit is set to 0, the size of the returned slice will not be limited. This can
// be very memory intensive so you probably shouldn't do this!
// In case of no entries, a 'no entries' error will be returned
GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]*gtsmodel.Status, Error)
GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, Error)
GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error)

View File

@ -231,7 +231,7 @@ func (a *accountDB) CountAccountStatuses(ctx context.Context, accountID string)
Count(ctx)
}
func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]*gtsmodel.Status, db.Error) {
func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, db.Error) {
statuses := []*gtsmodel.Status{}
q := a.conn.
@ -247,14 +247,22 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li
q = q.Limit(limit)
}
if pinnedOnly {
q = q.Where("pinned = ?", true)
if excludeReplies {
q = q.WhereGroup(" AND ", whereEmptyOrNull("in_reply_to_id"))
}
if maxID != "" {
q = q.Where("id < ?", maxID)
}
if minID != "" {
q = q.Where("id > ?", minID)
}
if pinnedOnly {
q = q.Where("pinned = ?", true)
}
if mediaOnly {
q = q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.
@ -263,8 +271,8 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li
})
}
if excludeReplies {
q = q.WhereGroup(" AND ", whereEmptyOrNull("in_reply_to_id"))
if publicOnly {
q = q.Where("visibility = ?", gtsmodel.VisibilityPublic)
}
if err := q.Scan(ctx); err != nil {

View File

@ -135,6 +135,19 @@ func (f *federatingDB) NewID(ctx context.Context, t vocab.Type) (idURL *url.URL,
return idProp.GetIRI(), nil
}
}
case ap.ActivityCreate:
// CREATE
// ID might already be set on a Create, so check it here and return it if it is
create, ok := t.(vocab.ActivityStreamsCreate)
if !ok {
return nil, errors.New("newid: create couldn't be parsed into vocab.ActivityStreamsCreate")
}
idProp := create.GetJSONLDId()
if idProp != nil {
if idProp.IsIRI() {
return idProp.GetIRI(), nil
}
}
case ap.ActivityAnnounce:
// ANNOUNCE aka BOOST
// ID might already be set on an announce we've created, so check it here and return it if it is

View File

@ -38,8 +38,8 @@ func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form
return p.accountProcessor.Update(ctx, authed.Account, form)
}
func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) {
return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly)
func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) {
return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
}
func (p *processor) AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) {

View File

@ -50,7 +50,7 @@ type Processor interface {
Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
// the account given in authed.
StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode)
StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode)
// FollowersGet fetches a list of the target account's followers.
FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
// FollowingGet fetches a list of the accounts that target account is following.

View File

@ -139,7 +139,7 @@ func (p *processor) Delete(ctx context.Context, account *gtsmodel.Account, origi
var maxID string
selectStatusesLoop:
for {
statuses, err := p.db.GetAccountStatuses(ctx, account.ID, 20, false, maxID, false, false)
statuses, err := p.db.GetAccountStatuses(ctx, account.ID, 20, false, maxID, "", false, false, false)
if err != nil {
if err == db.ErrNoEntries {
// no statuses left for this instance so we're done

View File

@ -28,7 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) {
func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) {
if blocked, err := p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil {
return nil, gtserror.NewErrorInternalError(err)
} else if blocked {
@ -37,7 +37,7 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
apiStatuses := []apimodel.Status{}
statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly)
statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
if err != nil {
if err == db.ErrNoEntries {
return apiStatuses, nil

View File

@ -20,418 +20,49 @@ package processing
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *processor) GetFediUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
var requestedPerson vocab.ActivityStreamsPerson
if util.IsPublicKeyPath(requestURL) {
// if it's a public key path, we don't need to authenticate but we'll only serve the bare minimum user profile needed for the public key
requestedPerson, err = p.tc.AccountToASMinimal(ctx, requestedAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else if util.IsUserPath(requestURL) {
// if it's a user path, we want to fully authenticate the request before we serve any data, and then we can serve a more complete profile
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
// if we're not already handshaking/dereferencing a remote account, dereference it now
if !p.federator.Handshaking(ctx, requestedUsername, requestingAccountURI) {
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
}
requestedPerson, err = p.tc.AccountToAS(ctx, requestedAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else {
return nil, gtserror.NewErrorBadRequest(fmt.Errorf("path was not public key path or user path"))
}
data, err := streams.Serialize(requestedPerson)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
return p.federationProcessor.GetUser(ctx, requestedUsername, requestURL)
}
func (p *processor) GetFediFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
requestedAccountURI, err := url.Parse(requestedAccount.URI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
}
requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err))
}
data, err := streams.Serialize(requestedFollowers)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
return p.federationProcessor.GetFollowers(ctx, requestedUsername, requestURL)
}
func (p *processor) GetFediFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
requestedAccountURI, err := url.Parse(requestedAccount.URI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
}
requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err))
}
data, err := streams.Serialize(requestedFollowing)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
return p.federationProcessor.GetFollowing(ctx, requestedUsername, requestURL)
}
func (p *processor) GetFediStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
// authorize the request:
// 1. check if a block exists between the requester and the requestee
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
// get the status out of the database here
s := &gtsmodel.Status{}
if err := p.db.GetWhere(ctx, []db.Where{
{Key: "id", Value: requestedStatusID},
{Key: "account_id", Value: requestedAccount.ID},
}, s); err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
}
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if !visible {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
}
// requester is authorized to view the status, so convert it to AP representation and serialize it
asStatus, err := p.tc.StatusToAS(ctx, s)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err := streams.Serialize(asStatus)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
return p.federationProcessor.GetStatus(ctx, requestedUsername, requestedStatusID, requestURL)
}
func (p *processor) GetFediStatusReplies(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
return p.federationProcessor.GetStatusReplies(ctx, requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, requestURL)
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
// authorize the request:
// 1. check if a block exists between the requester and the requestee
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
// get the status out of the database here
s := &gtsmodel.Status{}
if err := p.db.GetWhere(ctx, []db.Where{
{Key: "id", Value: requestedStatusID},
{Key: "account_id", Value: requestedAccount.ID},
}, s); err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
}
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if !visible {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
}
var data map[string]interface{}
// now there are three scenarios:
// 1. we're asked for the whole collection and not a page -- we can just return the collection, with no items, but a link to 'first' page.
// 2. we're asked for a page but only_other_accounts has not been set in the query -- so we should just return the first page of the collection, with no items.
// 3. we're asked for a page, and only_other_accounts has been set, and min_id has optionally been set -- so we need to return some actual items!
if !page {
// scenario 1
// get the collection
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err = streams.Serialize(collection)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else if page && requestURL.Query().Get("only_other_accounts") == "" {
// scenario 2
// get the collection
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
// but only return the first page
data, err = streams.Serialize(collection.GetActivityStreamsFirst().GetActivityStreamsCollectionPage())
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else {
// scenario 3
// get immediate children
replies, err := p.db.GetStatusChildren(ctx, s, true, minID)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
// filter children and extract URIs
replyURIs := map[string]*url.URL{}
for _, r := range replies {
// only show public or unlocked statuses as replies
if r.Visibility != gtsmodel.VisibilityPublic && r.Visibility != gtsmodel.VisibilityUnlocked {
continue
}
// respect onlyOtherAccounts parameter
if onlyOtherAccounts && r.AccountID == requestedAccount.ID {
continue
}
// only show replies that the status owner can see
visibleToStatusOwner, err := p.filter.StatusVisible(ctx, r, requestedAccount)
if err != nil || !visibleToStatusOwner {
continue
}
// only show replies that the requester can see
visibleToRequester, err := p.filter.StatusVisible(ctx, r, requestingAccount)
if err != nil || !visibleToRequester {
continue
}
rURI, err := url.Parse(r.URI)
if err != nil {
continue
}
replyURIs[r.ID] = rURI
}
repliesPage, err := p.tc.StatusURIsToASRepliesPage(ctx, s, onlyOtherAccounts, minID, replyURIs)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err = streams.Serialize(repliesPage)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
}
return data, nil
func (p *processor) GetFediOutbox(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
return p.federationProcessor.GetOutbox(ctx, requestedUsername, page, maxID, minID, requestURL)
}
func (p *processor) GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// return the webfinger representation
return &apimodel.WellKnownResponse{
Subject: fmt.Sprintf("acct:%s@%s", requestedAccount.Username, p.config.AccountDomain),
Aliases: []string{
requestedAccount.URI,
requestedAccount.URL,
},
Links: []apimodel.Link{
{
Rel: "http://webfinger.net/rel/profile-page",
Type: "text/html",
Href: requestedAccount.URL,
},
{
Rel: "self",
Type: "application/activity+json",
Href: requestedAccount.URI,
},
},
}, nil
return p.federationProcessor.GetWebfingerAccount(ctx, requestedUsername)
}
func (p *processor) GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) {
return &apimodel.WellKnownResponse{
Links: []apimodel.Link{
{
Rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
Href: fmt.Sprintf("%s://%s/nodeinfo/2.0", p.config.Protocol, p.config.Host),
},
},
}, nil
return p.federationProcessor.GetNodeInfoRel(ctx, request)
}
func (p *processor) GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode) {
return &apimodel.Nodeinfo{
Version: "2.0",
Software: apimodel.NodeInfoSoftware{
Name: "gotosocial",
Version: p.config.SoftwareVersion,
},
Protocols: []string{"activitypub"},
Services: apimodel.NodeInfoServices{
Inbound: []string{},
Outbound: []string{},
},
OpenRegistrations: p.config.AccountsConfig.OpenRegistration,
Usage: apimodel.NodeInfoUsage{
Users: apimodel.NodeInfoUsers{},
},
Metadata: make(map[string]interface{}),
}, nil
return p.federationProcessor.GetNodeInfo(ctx, request)
}
func (p *processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
contextWithChannel := context.WithValue(ctx, util.APFromFederatorChanKey, p.fromFederator)
return p.federator.FederatingActor().PostInbox(contextWithChannel, w, r)
return p.federationProcessor.PostInbox(ctx, w, r)
}

View File

@ -0,0 +1,103 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"net/http"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
)
// Processor wraps functions for processing federation API requests.
type Processor interface {
// GetUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication
// before returning a JSON serializable interface to the caller.
GetUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetFollowers handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
GetFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetFollowing handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
GetFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetStatus handles the getting of a fedi/activitypub representation of a particular status, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
GetStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetStatus handles the getting of a fedi/activitypub representation of replies to a status, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
GetStatusReplies(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups.
GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode)
// GetNodeInfoRel returns a well known response giving the path to node info.
GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode)
// GetNodeInfo returns a node info struct in response to a node info request.
GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode)
// GetOutbox returns the activitypub representation of a local user's outbox.
// This contains links to PUBLIC posts made by this user.
GetOutbox(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// PostInbox handles POST requests to a user's inbox for new activitypub messages.
//
// PostInbox returns true if the request was handled as an ActivityPub POST to an actor's inbox.
// If false, the request was not an ActivityPub request and may still be handled by the caller in another way, such as serving a web page.
//
// If the error is nil, then the ResponseWriter's headers and response has already been written. If a non-nil error is returned, then no response has been written.
//
// If the Actor was constructed with the Federated Protocol enabled, side effects will occur.
//
// If the Federated Protocol is not enabled, writes the http.StatusMethodNotAllowed status code in the response. No side effects occur.
PostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error)
}
type processor struct {
db db.DB
config *config.Config
federator federation.Federator
tc typeutils.TypeConverter
filter visibility.Filter
fromFederator chan messages.FromFederator
}
// New returns a new federation processor.
func New(db db.DB, tc typeutils.TypeConverter, config *config.Config, federator federation.Federator, fromFederator chan messages.FromFederator) Processor {
return &processor{
db: db,
config: config,
federator: federator,
tc: tc,
filter: visibility.NewFilter(db),
fromFederator: fromFederator,
}
}

View File

@ -0,0 +1,74 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
func (p *processor) GetFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
requestedAccountURI, err := url.Parse(requestedAccount.URI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
}
requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err))
}
data, err := streams.Serialize(requestedFollowers)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}

View File

@ -0,0 +1,74 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
func (p *processor) GetFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
requestedAccountURI, err := url.Parse(requestedAccount.URI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
}
requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err))
}
data, err := streams.Serialize(requestedFollowing)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}

View File

@ -0,0 +1,69 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"fmt"
"net/http"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
const (
nodeInfoVersion = "2.0"
nodeInfoSoftwareName = "gotosocial"
)
var (
nodeInfoRel = fmt.Sprintf("http://nodeinfo.diaspora.software/ns/schema/%s", nodeInfoVersion)
nodeInfoProtocols = []string{"activitypub"}
)
func (p *processor) GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) {
return &apimodel.WellKnownResponse{
Links: []apimodel.Link{
{
Rel: nodeInfoRel,
Href: fmt.Sprintf("%s://%s/nodeinfo/%s", p.config.Protocol, p.config.Host, nodeInfoVersion),
},
},
}, nil
}
func (p *processor) GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode) {
return &apimodel.Nodeinfo{
Version: nodeInfoVersion,
Software: apimodel.NodeInfoSoftware{
Name: nodeInfoSoftwareName,
Version: p.config.SoftwareVersion,
},
Protocols: nodeInfoProtocols,
Services: apimodel.NodeInfoServices{
Inbound: []string{},
Outbound: []string{},
},
OpenRegistrations: p.config.AccountsConfig.OpenRegistration,
Usage: apimodel.NodeInfoUsage{
Users: apimodel.NodeInfoUsers{},
},
Metadata: make(map[string]interface{}),
}, nil
}

View File

@ -0,0 +1,107 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
func (p *processor) GetOutbox(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
// authorize the request:
// 1. check if a block exists between the requester and the requestee
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
var data map[string]interface{}
// now there are two scenarios:
// 1. we're asked for the whole collection and not a page -- we can just return the collection, with no items, but a link to 'first' page.
// 2. we're asked for a specific page; this can be either the first page or any other page
if !page {
/*
scenario 1: return the collection with no items
we want something that looks like this:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.org/users/whatever/outbox",
"type": "OrderedCollection",
"first": "https://example.org/users/whatever/outbox?page=true",
"last": "https://example.org/users/whatever/outbox?min_id=0&page=true"
}
*/
collection, err := p.tc.OutboxToASCollection(ctx, requestedAccount.OutboxURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err = streams.Serialize(collection)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}
// scenario 2 -- get the requested page
// limit pages to 30 entries per page
publicStatuses, err := p.db.GetAccountStatuses(ctx, requestedAccount.ID, 30, true, maxID, minID, false, false, true)
if err != nil && err != db.ErrNoEntries {
return nil, gtserror.NewErrorInternalError(err)
}
outboxPage, err := p.tc.StatusesToASOutboxPage(ctx, requestedAccount.OutboxURI, maxID, minID, publicStatuses)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err = streams.Serialize(outboxPage)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}

View File

@ -0,0 +1,91 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func (p *processor) GetStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
// authorize the request:
// 1. check if a block exists between the requester and the requestee
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
// get the status out of the database here
s := &gtsmodel.Status{}
if err := p.db.GetWhere(ctx, []db.Where{
{Key: "id", Value: requestedStatusID},
{Key: "account_id", Value: requestedAccount.ID},
}, s); err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
}
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if !visible {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
}
// requester is authorized to view the status, so convert it to AP representation and serialize it
asStatus, err := p.tc.StatusToAS(ctx, s)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err := streams.Serialize(asStatus)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}

View File

@ -0,0 +1,164 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func (p *processor) GetStatusReplies(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
// authorize the request:
// 1. check if a block exists between the requester and the requestee
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
// get the status out of the database here
s := &gtsmodel.Status{}
if err := p.db.GetWhere(ctx, []db.Where{
{Key: "id", Value: requestedStatusID},
{Key: "account_id", Value: requestedAccount.ID},
}, s); err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
}
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if !visible {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
}
var data map[string]interface{}
// now there are three scenarios:
// 1. we're asked for the whole collection and not a page -- we can just return the collection, with no items, but a link to 'first' page.
// 2. we're asked for a page but only_other_accounts has not been set in the query -- so we should just return the first page of the collection, with no items.
// 3. we're asked for a page, and only_other_accounts has been set, and min_id has optionally been set -- so we need to return some actual items!
if !page {
// scenario 1
// get the collection
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err = streams.Serialize(collection)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else if page && requestURL.Query().Get("only_other_accounts") == "" {
// scenario 2
// get the collection
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
// but only return the first page
data, err = streams.Serialize(collection.GetActivityStreamsFirst().GetActivityStreamsCollectionPage())
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else {
// scenario 3
// get immediate children
replies, err := p.db.GetStatusChildren(ctx, s, true, minID)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
// filter children and extract URIs
replyURIs := map[string]*url.URL{}
for _, r := range replies {
// only show public or unlocked statuses as replies
if r.Visibility != gtsmodel.VisibilityPublic && r.Visibility != gtsmodel.VisibilityUnlocked {
continue
}
// respect onlyOtherAccounts parameter
if onlyOtherAccounts && r.AccountID == requestedAccount.ID {
continue
}
// only show replies that the status owner can see
visibleToStatusOwner, err := p.filter.StatusVisible(ctx, r, requestedAccount)
if err != nil || !visibleToStatusOwner {
continue
}
// only show replies that the requester can see
visibleToRequester, err := p.filter.StatusVisible(ctx, r, requestingAccount)
if err != nil || !visibleToRequester {
continue
}
rURI, err := url.Parse(r.URI)
if err != nil {
continue
}
replyURIs[r.ID] = rURI
}
repliesPage, err := p.tc.StatusURIsToASRepliesPage(ctx, s, onlyOtherAccounts, minID, replyURIs)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err = streams.Serialize(repliesPage)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
}
return data, nil
}

View File

@ -0,0 +1,85 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *processor) GetUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
var requestedPerson vocab.ActivityStreamsPerson
if util.IsPublicKeyPath(requestURL) {
// if it's a public key path, we don't need to authenticate but we'll only serve the bare minimum user profile needed for the public key
requestedPerson, err = p.tc.AccountToASMinimal(ctx, requestedAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else if util.IsUserPath(requestURL) {
// if it's a user path, we want to fully authenticate the request before we serve any data, and then we can serve a more complete profile
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if err != nil || !authenticated {
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
}
// if we're not already handshaking/dereferencing a remote account, dereference it now
if !p.federator.Handshaking(ctx, requestedUsername, requestingAccountURI) {
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
if err != nil {
return nil, gtserror.NewErrorNotAuthorized(err)
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
}
requestedPerson, err = p.tc.AccountToAS(ctx, requestedAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
} else {
return nil, gtserror.NewErrorBadRequest(fmt.Errorf("path was not public key path or user path"))
}
data, err := streams.Serialize(requestedPerson)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}

View File

@ -0,0 +1,64 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
const (
webfingerProfilePage = "http://webfinger.net/rel/profile-page"
webFingerProfilePageContentType = "text/html"
webfingerSelf = "self"
webFingerSelfContentType = "application/activity+json"
webfingerAccount = "acct"
)
func (p *processor) GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// return the webfinger representation
return &apimodel.WellKnownResponse{
Subject: fmt.Sprintf("%s:%s@%s", webfingerAccount, requestedAccount.Username, p.config.AccountDomain),
Aliases: []string{
requestedAccount.URI,
requestedAccount.URL,
},
Links: []apimodel.Link{
{
Rel: webfingerProfilePage,
Type: webFingerProfilePageContentType,
Href: requestedAccount.URL,
},
{
Rel: webfingerSelf,
Type: webFingerSelfContentType,
Href: requestedAccount.URI,
},
},
}, nil
}

View File

@ -0,0 +1,32 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 federation
import (
"context"
"net/http"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *processor) PostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
// pass the fromFederator channel through to postInbox, since it'll be needed later
contextWithChannel := context.WithValue(ctx, util.APFromFederatorChanKey, p.fromFederator)
return p.federator.FederatingActor().PostInbox(contextWithChannel, w, r)
}

View File

@ -269,12 +269,17 @@ func (p *processor) federateStatus(ctx context.Context, status *gtsmodel.Status)
return fmt.Errorf("federateStatus: error converting status to as format: %s", err)
}
create, err := p.tc.WrapNoteInCreate(asStatus, false)
if err != nil {
return fmt.Errorf("federateStatus: error wrapping status in create: %s", err)
}
outboxIRI, err := url.Parse(status.Account.OutboxURI)
if err != nil {
return fmt.Errorf("federateStatus: error parsing outboxURI %s: %s", status.Account.OutboxURI, err)
}
_, err = p.federator.FederatingActor().Send(ctx, outboxIRI, asStatus)
_, err = p.federator.FederatingActor().Send(ctx, outboxIRI, create)
return err
}

View File

@ -339,7 +339,7 @@ func (suite *FromFederatorTestSuite) TestProcessAccountDelete() {
suite.False(zorkFollowsSatan)
// no statuses from foss satan should be left in the database
dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", false, false)
dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", "", false, false, false)
suite.ErrorIs(err, db.ErrNoEntries)
suite.Empty(dbStatuses)

View File

@ -36,6 +36,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing/account"
"github.com/superseriousbusiness/gotosocial/internal/processing/admin"
federationProcessor "github.com/superseriousbusiness/gotosocial/internal/processing/federation"
mediaProcessor "github.com/superseriousbusiness/gotosocial/internal/processing/media"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
"github.com/superseriousbusiness/gotosocial/internal/processing/streaming"
@ -78,7 +79,7 @@ type Processor interface {
AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
// AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
// the account given in authed.
AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode)
AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode)
// AccountFollowersGet fetches a list of the target account's followers.
AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
// AccountFollowingGet fetches a list of the accounts that target account is following.
@ -190,32 +191,26 @@ type Processor interface {
// GetFediUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication
// before returning a JSON serializable interface to the caller.
GetFediUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetFediFollowers handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
GetFediFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetFediFollowing handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
GetFediFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetFediStatus handles the getting of a fedi/activitypub representation of a particular status, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
GetFediStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetFediStatus handles the getting of a fedi/activitypub representation of replies to a status, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
GetFediStatusReplies(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetFediOutbox returns the public outbox of the requested user, with the given parameters.
GetFediOutbox(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
// GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups.
GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode)
// GetNodeInfoRel returns a well known response giving the path to node info.
GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode)
// GetNodeInfo returns a node info struct in response to a node info request.
GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode)
// InboxPost handles POST requests to a user's inbox for new activitypub messages.
//
// InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox.
@ -248,12 +243,13 @@ type processor struct {
SUB-PROCESSORS
*/
accountProcessor account.Processor
adminProcessor admin.Processor
statusProcessor status.Processor
streamingProcessor streaming.Processor
mediaProcessor mediaProcessor.Processor
userProcessor user.Processor
accountProcessor account.Processor
adminProcessor admin.Processor
statusProcessor status.Processor
streamingProcessor streaming.Processor
mediaProcessor mediaProcessor.Processor
userProcessor user.Processor
federationProcessor federationProcessor.Processor
}
// NewProcessor returns a new Processor that uses the given federator
@ -267,6 +263,7 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f
adminProcessor := admin.New(db, tc, mediaHandler, fromClientAPI, config)
mediaProcessor := mediaProcessor.New(db, tc, mediaHandler, storage, config)
userProcessor := user.New(db, config)
federationProcessor := federationProcessor.New(db, tc, config, federator, fromFederator)
return &processor{
fromClientAPI: fromClientAPI,
@ -282,12 +279,13 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f
db: db,
filter: visibility.NewFilter(db),
accountProcessor: accountProcessor,
adminProcessor: adminProcessor,
statusProcessor: statusProcessor,
streamingProcessor: streamingProcessor,
mediaProcessor: mediaProcessor,
userProcessor: userProcessor,
accountProcessor: accountProcessor,
adminProcessor: adminProcessor,
statusProcessor: statusProcessor,
streamingProcessor: streamingProcessor,
mediaProcessor: mediaProcessor,
userProcessor: userProcessor,
federationProcessor: federationProcessor,
}
}

View File

@ -155,6 +155,19 @@ type TypeConverter interface {
StatusToASRepliesCollection(ctx context.Context, status *gtsmodel.Status, onlyOtherAccounts bool) (vocab.ActivityStreamsCollection, error)
// StatusURIsToASRepliesPage returns a collection page with appropriate next/part of pagination.
StatusURIsToASRepliesPage(ctx context.Context, status *gtsmodel.Status, onlyOtherAccounts bool, minID string, replies map[string]*url.URL) (vocab.ActivityStreamsCollectionPage, error)
// OutboxToASCollection returns an ordered collection with appropriate id, next, and last fields.
// The returned collection won't have any actual entries; just links to where entries can be obtained.
OutboxToASCollection(ctx context.Context, outboxID string) (vocab.ActivityStreamsOrderedCollection, error)
// StatusesToASOutboxPage returns an ordered collection page using the given statuses and parameters as contents.
//
// The maxID and minID should be the parameters that were passed to the database to obtain the given statuses.
// These will be used to create the 'id' field of the collection.
//
// OutboxID is used to create the 'partOf' field in the collection.
//
// Appropriate 'next' and 'prev' fields will be created based on the highest and lowest IDs present in the statuses slice.
StatusesToASOutboxPage(ctx context.Context, outboxID string, maxID string, minID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollectionPage, error)
/*
INTERNAL (gts) MODEL TO INTERNAL MODEL
*/
@ -170,6 +183,12 @@ type TypeConverter interface {
// WrapPersonInUpdate
WrapPersonInUpdate(person vocab.ActivityStreamsPerson, originAccount *gtsmodel.Account) (vocab.ActivityStreamsUpdate, error)
// WrapNoteInCreate wraps a Note with a Create activity.
//
// If objectIRIOnly is set to true, then the function won't put the *entire* note in the Object field of the Create,
// but just the AP URI of the note. This is useful in cases where you want to give a remote server something to dereference,
// and still have control over whether or not they're allowed to actually see the contents.
WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOnly bool) (vocab.ActivityStreamsCreate, error)
}
type converter struct {

View File

@ -32,6 +32,13 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// const (
// // highestID is the highest possible ULID
// highestID = "ZZZZZZZZZZZZZZZZZZZZZZZZZZ"
// // lowestID is the lowest possible ULID
// lowestID = "00000000000000000000000000"
// )
// Converts a gts model account into an Activity Streams person type.
func (c *converter) AccountToAS(ctx context.Context, a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) {
person := streams.NewActivityStreamsPerson()
@ -1013,3 +1020,140 @@ func (c *converter) StatusURIsToASRepliesPage(ctx context.Context, status *gtsmo
return page, nil
}
/*
the goal is to end up with something like this:
{
"id": "https://example.org/users/whatever/outbox?page=true",
"type": "OrderedCollectionPage",
"next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
"prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
"partOf": "https://example.org/users/whatever/outbox",
"orderedItems": [
"id": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7/activity",
"type": "Create",
"actor": "https://example.org/users/whatever",
"published": "2021-10-18T20:06:18Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.org/users/whatever/followers"
],
"object": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7"
]
}
*/
func (c *converter) StatusesToASOutboxPage(ctx context.Context, outboxID string, maxID string, minID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollectionPage, error) {
page := streams.NewActivityStreamsOrderedCollectionPage()
// .id
pageIDProp := streams.NewJSONLDIdProperty()
pageID := fmt.Sprintf("%s?page=true", outboxID)
if minID != "" {
pageID = fmt.Sprintf("%s&minID=%s", pageID, minID)
}
if maxID != "" {
pageID = fmt.Sprintf("%s&maxID=%s", pageID, maxID)
}
pageIDURI, err := url.Parse(pageID)
if err != nil {
return nil, err
}
pageIDProp.SetIRI(pageIDURI)
page.SetJSONLDId(pageIDProp)
// .partOf
collectionIDURI, err := url.Parse(outboxID)
if err != nil {
return nil, err
}
partOfProp := streams.NewActivityStreamsPartOfProperty()
partOfProp.SetIRI(collectionIDURI)
page.SetActivityStreamsPartOf(partOfProp)
// .orderedItems
itemsProp := streams.NewActivityStreamsOrderedItemsProperty()
var highest string
var lowest string
for _, s := range statuses {
note, err := c.StatusToAS(ctx, s)
if err != nil {
return nil, err
}
create, err := c.WrapNoteInCreate(note, true)
if err != nil {
return nil, err
}
itemsProp.AppendActivityStreamsCreate(create)
if highest == "" || s.ID > highest {
highest = s.ID
}
if lowest == "" || s.ID < lowest {
lowest = s.ID
}
}
page.SetActivityStreamsOrderedItems(itemsProp)
// .next
if lowest != "" {
nextProp := streams.NewActivityStreamsNextProperty()
nextPropIDString := fmt.Sprintf("%s?page=true&max_id=%s", outboxID, lowest)
nextPropIDURI, err := url.Parse(nextPropIDString)
if err != nil {
return nil, err
}
nextProp.SetIRI(nextPropIDURI)
page.SetActivityStreamsNext(nextProp)
}
// .prev
if highest != "" {
prevProp := streams.NewActivityStreamsPrevProperty()
prevPropIDString := fmt.Sprintf("%s?page=true&min_id=%s", outboxID, highest)
prevPropIDURI, err := url.Parse(prevPropIDString)
if err != nil {
return nil, err
}
prevProp.SetIRI(prevPropIDURI)
page.SetActivityStreamsPrev(prevProp)
}
return page, nil
}
/*
we want something that looks like this:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.org/users/whatever/outbox",
"type": "OrderedCollection",
"first": "https://example.org/users/whatever/outbox?page=true"
}
*/
func (c *converter) OutboxToASCollection(ctx context.Context, outboxID string) (vocab.ActivityStreamsOrderedCollection, error) {
collection := streams.NewActivityStreamsOrderedCollection()
collectionIDProp := streams.NewJSONLDIdProperty()
outboxIDURI, err := url.Parse(outboxID)
if err != nil {
return nil, fmt.Errorf("error parsing url %s", outboxID)
}
collectionIDProp.SetIRI(outboxIDURI)
collection.SetJSONLDId(collectionIDProp)
collectionFirstProp := streams.NewActivityStreamsFirstProperty()
collectionFirstPropID := fmt.Sprintf("%s?page=true", outboxID)
collectionFirstPropIDURI, err := url.Parse(collectionFirstPropID)
if err != nil {
return nil, fmt.Errorf("error parsing url %s", collectionFirstPropID)
}
collectionFirstProp.SetIRI(collectionFirstPropIDURI)
collection.SetActivityStreamsFirst(collectionFirstProp)
return collection, nil
}

View File

@ -37,18 +37,98 @@ func (suite *InternalToASTestSuite) TestAccountToAS() {
testAccount := suite.testAccounts["local_account_1"] // take zork for this test
asPerson, err := suite.typeconverter.AccountToAS(context.Background(), testAccount)
assert.NoError(suite.T(), err)
suite.NoError(err)
ser, err := streams.Serialize(asPerson)
assert.NoError(suite.T(), err)
suite.NoError(err)
bytes, err := json.Marshal(ser)
assert.NoError(suite.T(), err)
suite.NoError(err)
fmt.Println(string(bytes))
// TODO: write assertions here, rn we're just eyeballing the output
}
func (suite *InternalToASTestSuite) TestOutboxToASCollection() {
testAccount := suite.testAccounts["admin_account"]
ctx := context.Background()
collection, err := suite.typeconverter.OutboxToASCollection(ctx, testAccount.OutboxURI)
suite.NoError(err)
ser, err := streams.Serialize(collection)
assert.NoError(suite.T(), err)
bytes, err := json.Marshal(ser)
suite.NoError(err)
/*
we want this:
{
"@context": "https://www.w3.org/ns/activitystreams",
"first": "http://localhost:8080/users/admin/outbox?page=true",
"id": "http://localhost:8080/users/admin/outbox",
"type": "OrderedCollection"
}
*/
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","first":"http://localhost:8080/users/admin/outbox?page=true","id":"http://localhost:8080/users/admin/outbox","type":"OrderedCollection"}`, string(bytes))
}
func (suite *InternalToASTestSuite) TestStatusesToASOutboxPage() {
testAccount := suite.testAccounts["admin_account"]
ctx := context.Background()
// get public statuses from testaccount
statuses, err := suite.db.GetAccountStatuses(ctx, testAccount.ID, 30, true, "", "", false, false, true)
suite.NoError(err)
page, err := suite.typeconverter.StatusesToASOutboxPage(ctx, testAccount.OutboxURI, "", "", statuses)
suite.NoError(err)
ser, err := streams.Serialize(page)
assert.NoError(suite.T(), err)
bytes, err := json.Marshal(ser)
suite.NoError(err)
/*
we want this:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://localhost:8080/users/admin/outbox?page=true",
"next": "http://localhost:8080/users/admin/outbox?page=true&max_id=01F8MH75CBF9JFX4ZAD54N0W0R",
"orderedItems": [
{
"actor": "http://localhost:8080/users/admin",
"cc": "http://localhost:8080/users/admin/followers",
"id": "http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37/activity",
"object": "http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37",
"published": "2021-10-20T12:36:45Z",
"to": "https://www.w3.org/ns/activitystreams#Public",
"type": "Create"
},
{
"actor": "http://localhost:8080/users/admin",
"cc": "http://localhost:8080/users/admin/followers",
"id": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/activity",
"object": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"published": "2021-10-20T11:36:45Z",
"to": "https://www.w3.org/ns/activitystreams#Public",
"type": "Create"
}
],
"partOf": "http://localhost:8080/users/admin/outbox",
"prev": "http://localhost:8080/users/admin/outbox?page=true&min_id=01F8MHAAY43M6RJ473VQFCVH37",
"type": "OrderedCollectionPage"
}
*/
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/admin/outbox?page=true","next":"http://localhost:8080/users/admin/outbox?page=true\u0026max_id=01F8MH75CBF9JFX4ZAD54N0W0R","orderedItems":[{"actor":"http://localhost:8080/users/admin","cc":"http://localhost:8080/users/admin/followers","id":"http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37/activity","object":"http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37","published":"2021-10-20T12:36:45Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"},{"actor":"http://localhost:8080/users/admin","cc":"http://localhost:8080/users/admin/followers","id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/activity","object":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","published":"2021-10-20T11:36:45Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"}],"partOf":"http://localhost:8080/users/admin/outbox","prev":"http://localhost:8080/users/admin/outbox?page=true\u0026min_id=01F8MHAAY43M6RJ473VQFCVH37","type":"OrderedCollectionPage"}`, string(bytes))
}
func TestInternalToASTestSuite(t *testing.T) {
suite.Run(t, new(InternalToASTestSuite))
}

View File

@ -7,6 +7,7 @@ import (
"github.com/go-fed/activity/pub"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/util"
@ -66,3 +67,66 @@ func (c *converter) WrapPersonInUpdate(person vocab.ActivityStreamsPerson, origi
return update, nil
}
func (c *converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOnly bool) (vocab.ActivityStreamsCreate, error) {
create := streams.NewActivityStreamsCreate()
// Object property
objectProp := streams.NewActivityStreamsObjectProperty()
if objectIRIOnly {
objectProp.AppendIRI(note.GetJSONLDId().GetIRI())
} else {
objectProp.AppendActivityStreamsNote(note)
}
create.SetActivityStreamsObject(objectProp)
// ID property
idProp := streams.NewJSONLDIdProperty()
createID := fmt.Sprintf("%s/activity", note.GetJSONLDId().GetIRI().String())
createIDIRI, err := url.Parse(createID)
if err != nil {
return nil, err
}
idProp.SetIRI(createIDIRI)
create.SetJSONLDId(idProp)
// Actor Property
actorProp := streams.NewActivityStreamsActorProperty()
actorIRI, err := ap.ExtractAttributedTo(note)
if err != nil {
return nil, fmt.Errorf("WrapNoteInCreate: couldn't extract AttributedTo: %s", err)
}
actorProp.AppendIRI(actorIRI)
create.SetActivityStreamsActor(actorProp)
// Published Property
publishedProp := streams.NewActivityStreamsPublishedProperty()
published, err := ap.ExtractPublished(note)
if err != nil {
return nil, fmt.Errorf("WrapNoteInCreate: couldn't extract Published: %s", err)
}
publishedProp.Set(published)
create.SetActivityStreamsPublished(publishedProp)
// To Property
toProp := streams.NewActivityStreamsToProperty()
tos, err := ap.ExtractTos(note)
if err == nil {
for _, to := range tos {
toProp.AppendIRI(to)
}
create.SetActivityStreamsTo(toProp)
}
// Cc Property
ccProp := streams.NewActivityStreamsCcProperty()
ccs, err := ap.ExtractCCs(note)
if err == nil {
for _, cc := range ccs {
ccProp.AppendIRI(cc)
}
create.SetActivityStreamsCc(ccProp)
}
return create, nil
}

View File

@ -0,0 +1,74 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
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 typeutils_test
import (
"context"
"encoding/json"
"testing"
"github.com/go-fed/activity/streams"
"github.com/stretchr/testify/suite"
)
type WrapTestSuite struct {
TypeUtilsTestSuite
}
func (suite *WrapTestSuite) TestWrapNoteInCreateIRIOnly() {
testStatus := suite.testStatuses["local_account_1_status_1"]
note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus)
suite.NoError(err)
create, err := suite.typeconverter.WrapNoteInCreate(note, true)
suite.NoError(err)
suite.NotNil(create)
createI, err := streams.Serialize(create)
suite.NoError(err)
bytes, err := json.Marshal(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","object":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY","published":"2021-10-20T12:40:37+02:00","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"}`, string(bytes))
}
func (suite *WrapTestSuite) TestWrapNoteInCreate() {
testStatus := suite.testStatuses["local_account_1_status_1"]
note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus)
suite.NoError(err)
create, err := suite.typeconverter.WrapNoteInCreate(note, false)
suite.NoError(err)
suite.NotNil(create)
createI, err := streams.Serialize(create)
suite.NoError(err)
bytes, err := json.Marshal(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","object":{"attachment":[],"attributedTo":"http://localhost:8080/users/the_mighty_zork","cc":"http://localhost:8080/users/the_mighty_zork/followers","content":"hello everyone!","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY","published":"2021-10-20T12:40:37+02:00","replies":{"first":{"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"Collection"},"summary":"introduction post","tag":[],"to":"https://www.w3.org/ns/activitystreams#Public","type":"Note","url":"http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY"},"published":"2021-10-20T12:40:37+02:00","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"}`, string(bytes))
}
func TestWrapTestSuite(t *testing.T) {
suite.Run(t, new(WrapTestSuite))
}

View File

@ -32,6 +32,10 @@ type Filter interface {
// or account domains, and other relevant accounts mentioned in or replied to by the status.
StatusVisible(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (bool, error)
// StatusesVisible calls StatusVisible for each status in the statuses slice, and returns a slice of only
// statuses which are visible to the requestingAccount.
StatusesVisible(ctx context.Context, statuses []*gtsmodel.Status, requestingAccount *gtsmodel.Account) ([]*gtsmodel.Status, error)
// StatusHometimelineable returns true if targetStatus should be in the home timeline of the requesting account.
//
// This function will call StatusVisible internally, so it's not necessary to call it beforehand.

View File

@ -239,3 +239,17 @@ func (f *filter) StatusVisible(ctx context.Context, targetStatus *gtsmodel.Statu
// If we reached here, all is okay
return true, nil
}
func (f *filter) StatusesVisible(ctx context.Context, statuses []*gtsmodel.Status, requestingAccount *gtsmodel.Account) ([]*gtsmodel.Status, error) {
filtered := []*gtsmodel.Status{}
for _, s := range statuses {
visible, err := f.StatusVisible(ctx, s, requestingAccount)
if err != nil {
return nil, err
}
if visible {
filtered = append(filtered, s)
}
}
return filtered, nil
}

View File

@ -484,7 +484,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
},
}
preserializedKeys := []string {
preserializedKeys := []string{
"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",
@ -495,7 +495,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
if diff := len(accounts) - len(preserializedKeys); diff > 0 {
var keyStrings = make([]string, diff)
for i := 0; i < diff; i ++ {
for i := 0; i < diff; i++ {
priv, _ := rsa.GenerateKey(rand.Reader, 2048)
key, _ := x509.MarshalPKCS8PrivateKey(priv)
keyStrings = append(keyStrings, base64.StdEncoding.EncodeToString(key))
@ -524,6 +524,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
}
return accounts
}
// NewTestAttachments returns a map of attachments keyed according to which account
// and status they belong to, and which attachment number of that status they are.
func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
@ -863,8 +864,8 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
TagIDs: []string{"01F8MHA1A2NF9MJ3WCCQ3K8BSZ"},
MentionIDs: []string{},
EmojiIDs: []string{"01F8MH9H8E4VG3KDYJR9EGPXCQ"},
CreatedAt: time.Now().Add(-71 * time.Hour),
UpdatedAt: time.Now().Add(-71 * time.Hour),
CreatedAt: TimeMustParse("2021-10-20T11:36:45Z"),
UpdatedAt: TimeMustParse("2021-10-20T11:36:45Z"),
Local: true,
AccountURI: "http://localhost:8080/users/admin",
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
@ -886,8 +887,8 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URI: "http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37",
URL: "http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37",
Content: "🐕🐕🐕🐕🐕",
CreatedAt: time.Now().Add(-70 * time.Hour),
UpdatedAt: time.Now().Add(-70 * time.Hour),
CreatedAt: TimeMustParse("2021-10-20T12:36:45Z"),
UpdatedAt: TimeMustParse("2021-10-20T12:36:45Z"),
Local: true,
AccountURI: "http://localhost:8080/users/admin",
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
@ -934,8 +935,8 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
Content: "hello everyone!",
CreatedAt: time.Now().Add(-47 * time.Hour),
UpdatedAt: time.Now().Add(-47 * time.Hour),
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
Local: true,
AccountURI: "http://localhost:8080/users/the_mighty_zork",
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
@ -1536,11 +1537,38 @@ func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[strin
DateHeader: date,
}
target = URLMustParse(accounts["local_account_1"].OutboxURI)
sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target)
fossSatanDereferenceZorkOutbox := ActivityWithSignature{
SignatureHeader: sig,
DigestHeader: digest,
DateHeader: date,
}
target = URLMustParse(accounts["local_account_1"].OutboxURI + "?page=true")
sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target)
fossSatanDereferenceZorkOutboxFirst := ActivityWithSignature{
SignatureHeader: sig,
DigestHeader: digest,
DateHeader: date,
}
target = URLMustParse(accounts["local_account_1"].OutboxURI + "?page=true&max_id=01F8MHAMCHF6Y650WCRSCP4WMY")
sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target)
fossSatanDereferenceZorkOutboxNext := ActivityWithSignature{
SignatureHeader: sig,
DigestHeader: digest,
DateHeader: date,
}
return map[string]ActivityWithSignature{
"foss_satan_dereference_zork": fossSatanDereferenceZork,
"foss_satan_dereference_local_account_1_status_1_replies": fossSatanDereferenceLocalAccount1Status1Replies,
"foss_satan_dereference_local_account_1_status_1_replies_next": fossSatanDereferenceLocalAccount1Status1RepliesNext,
"foss_satan_dereference_local_account_1_status_1_replies_last": fossSatanDereferenceLocalAccount1Status1RepliesLast,
"foss_satan_dereference_zork_outbox": fossSatanDereferenceZorkOutbox,
"foss_satan_dereference_zork_outbox_first": fossSatanDereferenceZorkOutboxFirst,
"foss_satan_dereference_zork_outbox_next": fossSatanDereferenceZorkOutboxNext,
}
}