diff options
author | 2021-10-24 11:57:39 +0200 | |
---|---|---|
committer | 2021-10-24 11:57:39 +0200 | |
commit | 4b1d9d3780134098ff06877abc20c970c32d4aac (patch) | |
tree | a46deccd4cdf2ddf9d0ea92f32bd8669657a4687 /internal | |
parent | pregenerate RSA keys for testrig accounts. If a user is added without a key,... (diff) | |
download | gotosocial-4b1d9d3780134098ff06877abc20c970c32d4aac.tar.xz |
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
Diffstat (limited to 'internal')
36 files changed, 1768 insertions, 462 deletions
diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go index bb11888d3..6e8b1e242 100644 --- a/internal/api/client/account/account.go +++ b/internal/api/client/account/account.go @@ -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" diff --git a/internal/api/client/account/statuses.go b/internal/api/client/account/statuses.go index ab1d4e2e6..9a60e80ee 100644 --- a/internal/api/client/account/statuses.go +++ b/internal/api/client/account/statuses.go @@ -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()}) diff --git a/internal/api/s2s/user/common.go b/internal/api/s2s/user/common.go index e96082c3e..9f426274d 100644 --- a/internal/api/s2s/user/common.go +++ b/internal/api/s2s/user/common.go @@ -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"` +} diff --git a/internal/api/s2s/user/inboxpost_test.go b/internal/api/s2s/user/inboxpost_test.go index 79c44116d..554f3d729 100644 --- a/internal/api/s2s/user/inboxpost_test.go +++ b/internal/api/s2s/user/inboxpost_test.go @@ -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) diff --git a/internal/api/s2s/user/outboxget.go b/internal/api/s2s/user/outboxget.go new file mode 100644 index 000000000..46f9d2ded --- /dev/null +++ b/internal/api/s2s/user/outboxget.go @@ -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) +} diff --git a/internal/api/s2s/user/outboxget_test.go b/internal/api/s2s/user/outboxget_test.go new file mode 100644 index 000000000..f1818683e --- /dev/null +++ b/internal/api/s2s/user/outboxget_test.go @@ -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)) +} diff --git a/internal/api/s2s/user/repliesget.go b/internal/api/s2s/user/repliesget.go index b24dead31..e6328a26b 100644 --- a/internal/api/s2s/user/repliesget.go +++ b/internal/api/s2s/user/repliesget.go @@ -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"` -} diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go index ef0057f98..56b940f1d 100644 --- a/internal/api/s2s/user/user.go +++ b/internal/api/s2s/user/user.go @@ -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 } diff --git a/internal/db/account.go b/internal/db/account.go index 058a89859..0d52ce39d 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -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) diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 745e41567..9def811fb 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -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 { diff --git a/internal/federation/federatingdb/util.go b/internal/federation/federatingdb/util.go index 3fd9653af..34a103a49 100644 --- a/internal/federation/federatingdb/util.go +++ b/internal/federation/federatingdb/util.go @@ -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 diff --git a/internal/processing/account.go b/internal/processing/account.go index 94ba596ac..f1c119083 100644 --- a/internal/processing/account.go +++ b/internal/processing/account.go @@ -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) { diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index e88cd3a94..4e807540c 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -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. diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index 4be0c859e..20198e8ef 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -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 diff --git a/internal/processing/account/getstatuses.go b/internal/processing/account/getstatuses.go index 56b5b0eae..bd327cae6 100644 --- a/internal/processing/account/getstatuses.go +++ b/internal/processing/account/getstatuses.go @@ -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 diff --git a/internal/processing/federation.go b/internal/processing/federation.go index 1336a6e46..023930494 100644 --- a/internal/processing/federation.go +++ b/internal/processing/federation.go @@ -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 := >smodel.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)) - } - - // 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 := >smodel.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 p.federationProcessor.GetStatusReplies(ctx, requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, requestURL) +} - 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) } diff --git a/internal/processing/federation/federation.go b/internal/processing/federation/federation.go new file mode 100644 index 000000000..b050406c5 --- /dev/null +++ b/internal/processing/federation/federation.go @@ -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, + } +} diff --git a/internal/processing/federation/getfollowers.go b/internal/processing/federation/getfollowers.go new file mode 100644 index 000000000..b17c90e07 --- /dev/null +++ b/internal/processing/federation/getfollowers.go @@ -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 +} diff --git a/internal/processing/federation/getfollowing.go b/internal/processing/federation/getfollowing.go new file mode 100644 index 000000000..e2d50d238 --- /dev/null +++ b/internal/processing/federation/getfollowing.go @@ -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 +} diff --git a/internal/processing/federation/getnodeinfo.go b/internal/processing/federation/getnodeinfo.go new file mode 100644 index 000000000..4eb8ad3b7 --- /dev/null +++ b/internal/processing/federation/getnodeinfo.go @@ -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 +} diff --git a/internal/processing/federation/getoutbox.go b/internal/processing/federation/getoutbox.go new file mode 100644 index 000000000..a3b2cff3c --- /dev/null +++ b/internal/processing/federation/getoutbox.go @@ -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 +} diff --git a/internal/processing/federation/getstatus.go b/internal/processing/federation/getstatus.go new file mode 100644 index 000000000..a4f251023 --- /dev/null +++ b/internal/processing/federation/getstatus.go @@ -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 := >smodel.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 +} diff --git a/internal/processing/federation/getstatusreplies.go b/internal/processing/federation/getstatusreplies.go new file mode 100644 index 000000000..0fa0cc386 --- /dev/null +++ b/internal/processing/federation/getstatusreplies.go @@ -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 := >smodel.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 +} diff --git a/internal/processing/federation/getuser.go b/internal/processing/federation/getuser.go new file mode 100644 index 000000000..0d80e528e --- /dev/null +++ b/internal/processing/federation/getuser.go @@ -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 +} diff --git a/internal/processing/federation/getwebfinger.go b/internal/processing/federation/getwebfinger.go new file mode 100644 index 000000000..aaa7687be --- /dev/null +++ b/internal/processing/federation/getwebfinger.go @@ -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 +} diff --git a/internal/processing/federation/postinbox.go b/internal/processing/federation/postinbox.go new file mode 100644 index 000000000..df9da0a51 --- /dev/null +++ b/internal/processing/federation/postinbox.go @@ -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) +} diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go index d4e8f5fa5..3054fbf57 100644 --- a/internal/processing/fromclientapi.go +++ b/internal/processing/fromclientapi.go @@ -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 } diff --git a/internal/processing/fromfederator_test.go b/internal/processing/fromfederator_test.go index 09519d1d3..6846357d1 100644 --- a/internal/processing/fromfederator_test.go +++ b/internal/processing/fromfederator_test.go @@ -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) diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 8fd372735..d0a636b27 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -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, } } diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 812c37935..eeb5bead1 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -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 { diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 73d274908..047d61b80 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -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 +} diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index 50f92e043..d8098098f 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -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)) } diff --git a/internal/typeutils/wrap.go b/internal/typeutils/wrap.go index d6aa11cb4..e7c422eba 100644 --- a/internal/typeutils/wrap.go +++ b/internal/typeutils/wrap.go @@ -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 +} diff --git a/internal/typeutils/wrap_test.go b/internal/typeutils/wrap_test.go new file mode 100644 index 000000000..14309c00c --- /dev/null +++ b/internal/typeutils/wrap_test.go @@ -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)) +} diff --git a/internal/visibility/filter.go b/internal/visibility/filter.go index e3435842e..9f4bb4aad 100644 --- a/internal/visibility/filter.go +++ b/internal/visibility/filter.go @@ -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. diff --git a/internal/visibility/statusvisible.go b/internal/visibility/statusvisible.go index 1e48abcda..a99494836 100644 --- a/internal/visibility/statusvisible.go +++ b/internal/visibility/statusvisible.go @@ -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 +} |