diff options
Diffstat (limited to 'internal/api/activitypub')
19 files changed, 2494 insertions, 0 deletions
diff --git a/internal/api/activitypub/emoji/emoji.go b/internal/api/activitypub/emoji/emoji.go new file mode 100644 index 000000000..f0eab0f02 --- /dev/null +++ b/internal/api/activitypub/emoji/emoji.go @@ -0,0 +1,47 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 emoji + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // EmojiIDKey is for emoji IDs + EmojiIDKey = "id" + // EmojiBasePath is the base path for serving AP Emojis, minus the "emoji" prefix + EmojiWithIDPath = "/:" + EmojiIDKey +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, EmojiWithIDPath, m.EmojiGetHandler) +} diff --git a/internal/api/activitypub/emoji/emojiget.go b/internal/api/activitypub/emoji/emojiget.go new file mode 100644 index 000000000..4e5ca25f4 --- /dev/null +++ b/internal/api/activitypub/emoji/emojiget.go @@ -0,0 +1,59 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 emoji + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +func (m *Module) EmojiGetHandler(c *gin.Context) { + requestedEmojiID := strings.ToUpper(c.Param(EmojiIDKey)) + if requestedEmojiID == "" { + err := errors.New("no emoji id specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.ActivityPubAcceptHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + resp, errWithCode := m.processor.GetFediEmoji(apiutil.TransferSignatureContext(c), requestedEmojiID, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/emoji/emojiget_test.go b/internal/api/activitypub/emoji/emojiget_test.go new file mode 100644 index 000000000..5b06f7a33 --- /dev/null +++ b/internal/api/activitypub/emoji/emojiget_test.go @@ -0,0 +1,137 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 emoji_test + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/emoji" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type EmojiGetTestSuite struct { + suite.Suite + db db.DB + tc typeutils.TypeConverter + mediaManager media.Manager + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *storage.Driver + + testEmojis map[string]*gtsmodel.Emoji + testAccounts map[string]*gtsmodel.Account + + emojiModule *emoji.Module + + signatureCheck gin.HandlerFunc +} + +func (suite *EmojiGetTestSuite) SetupSuite() { + suite.testAccounts = testrig.NewTestAccounts() + suite.testEmojis = testrig.NewTestEmojis() +} + +func (suite *EmojiGetTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + suite.db = testrig.NewTestDB() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.emojiModule = emoji.New(suite.processor) + testrig.StandardDBSetup(suite.db, suite.testAccounts) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + suite.signatureCheck = middleware.SignatureCheck(suite.db.IsURIBlocked) + + suite.NoError(suite.processor.Start()) +} + +func (suite *EmojiGetTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *EmojiGetTestSuite) TestGetEmoji() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_emoji"] + targetEmoji := suite.testEmojis["rainbow"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetEmoji.URI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: emoji.EmojiIDKey, + Value: targetEmoji.ID, + }, + } + + // trigger the function being tested + suite.emojiModule.EmojiGetHandler(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.Contains(string(b), `"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji"`) +} + +func TestEmojiGetTestSuite(t *testing.T) { + suite.Run(t, new(EmojiGetTestSuite)) +} diff --git a/internal/api/activitypub/users/common.go b/internal/api/activitypub/users/common.go new file mode 100644 index 000000000..bf7623774 --- /dev/null +++ b/internal/api/activitypub/users/common.go @@ -0,0 +1,57 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +// 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/activitypub/users/followers.go b/internal/api/activitypub/users/followers.go new file mode 100644 index 000000000..69b9f52fd --- /dev/null +++ b/internal/api/activitypub/users/followers.go @@ -0,0 +1,67 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// FollowersGETHandler returns a collection of URIs for followers of the target user, formatted so that other AP servers can understand it. +func (m *Module) FollowersGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.GetFediFollowers(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/following.go b/internal/api/activitypub/users/following.go new file mode 100644 index 000000000..64337d4cd --- /dev/null +++ b/internal/api/activitypub/users/following.go @@ -0,0 +1,67 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// FollowingGETHandler returns a collection of URIs for accounts that the target user follows, formatted so that other AP servers can understand it. +func (m *Module) FollowingGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.GetFediFollowing(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/inboxpost.go b/internal/api/activitypub/users/inboxpost.go new file mode 100644 index 000000000..1d8f9f923 --- /dev/null +++ b/internal/api/activitypub/users/inboxpost.go @@ -0,0 +1,51 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "errors" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" //nolint:typecheck +) + +// InboxPOSTHandler deals with incoming POST requests to an actor's inbox. +// Eg., POST to https://example.org/users/whatever/inbox. +func (m *Module) InboxPOSTHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if posted, err := m.processor.InboxPost(apiutil.TransferSignatureContext(c), c.Writer, c.Request); err != nil { + if withCode, ok := err.(gtserror.WithCode); ok { + apiutil.ErrorHandler(c, withCode, m.processor.InstanceGet) + } else { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + } + } else if !posted { + err := errors.New("unable to process request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + } +} diff --git a/internal/api/activitypub/users/inboxpost_test.go b/internal/api/activitypub/users/inboxpost_test.go new file mode 100644 index 000000000..53b6b8aac --- /dev/null +++ b/internal/api/activitypub/users/inboxpost_test.go @@ -0,0 +1,500 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users_test + +import ( + "bytes" + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/pub" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type InboxPostTestSuite struct { + UserStandardTestSuite +} + +func (suite *InboxPostTestSuite) TestPostBlock() { + blockingAccount := suite.testAccounts["remote_account_1"] + blockedAccount := suite.testAccounts["local_account_1"] + blockURI := testrig.URLMustParse("http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3") + + block := streams.NewActivityStreamsBlock() + + // set the actor property to the block-ing account's URI + actorProp := streams.NewActivityStreamsActorProperty() + actorIRI := testrig.URLMustParse(blockingAccount.URI) + actorProp.AppendIRI(actorIRI) + block.SetActivityStreamsActor(actorProp) + + // set the ID property to the blocks's URI + idProp := streams.NewJSONLDIdProperty() + idProp.Set(blockURI) + block.SetJSONLDId(idProp) + + // set the object property to the target account's URI + objectProp := streams.NewActivityStreamsObjectProperty() + targetIRI := testrig.URLMustParse(blockedAccount.URI) + objectProp.AppendIRI(targetIRI) + block.SetActivityStreamsObject(objectProp) + + // set the TO property to the target account's IRI + toProp := streams.NewActivityStreamsToProperty() + toIRI := testrig.URLMustParse(blockedAccount.URI) + toProp.AppendIRI(toIRI) + block.SetActivityStreamsTo(toProp) + + targetURI := testrig.URLMustParse(blockedAccount.InboxURI) + + signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(block, blockingAccount.PublicKeyURI, blockingAccount.PrivateKey, targetURI) + bodyI, err := streams.Serialize(block) + suite.NoError(err) + + bodyJson, err := json.Marshal(bodyI) + suite.NoError(err) + body := bytes.NewReader(bodyJson) + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting + ctx.Request.Header.Set("Signature", signature) + ctx.Request.Header.Set("Date", dateHeader) + ctx.Request.Header.Set("Digest", digestHeader) + ctx.Request.Header.Set("Content-Type", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.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: users.UsernameKey, + Value: blockedAccount.Username, + }, + } + + // trigger the function being tested + userModule.InboxPOSTHandler(ctx) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Empty(b) + + // there should be a block in the database now between the accounts + dbBlock, err := suite.db.GetBlock(context.Background(), blockingAccount.ID, blockedAccount.ID) + suite.NoError(err) + suite.NotNil(dbBlock) + suite.WithinDuration(time.Now(), dbBlock.CreatedAt, 30*time.Second) + suite.WithinDuration(time.Now(), dbBlock.UpdatedAt, 30*time.Second) + suite.Equal("http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3", dbBlock.URI) +} + +// TestPostUnblock verifies that a remote account with a block targeting one of our instance users should be able to undo that block. +func (suite *InboxPostTestSuite) TestPostUnblock() { + blockingAccount := suite.testAccounts["remote_account_1"] + blockedAccount := suite.testAccounts["local_account_1"] + + // first put a block in the database so we have something to undo + blockURI := "http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3" + dbBlockID, err := id.NewRandomULID() + suite.NoError(err) + + dbBlock := >smodel.Block{ + ID: dbBlockID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + URI: blockURI, + AccountID: blockingAccount.ID, + TargetAccountID: blockedAccount.ID, + } + + err = suite.db.PutBlock(context.Background(), dbBlock) + suite.NoError(err) + + asBlock, err := suite.tc.BlockToAS(context.Background(), dbBlock) + suite.NoError(err) + + targetAccountURI := testrig.URLMustParse(blockedAccount.URI) + + // create an Undo and set the appropriate actor on it + undo := streams.NewActivityStreamsUndo() + undo.SetActivityStreamsActor(asBlock.GetActivityStreamsActor()) + + // Set the block as the 'object' property. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsBlock(asBlock) + undo.SetActivityStreamsObject(undoObject) + + // Set the To of the undo as the target of the block + undoTo := streams.NewActivityStreamsToProperty() + undoTo.AppendIRI(targetAccountURI) + undo.SetActivityStreamsTo(undoTo) + + undoID := streams.NewJSONLDIdProperty() + undoID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/72cc96a3-f742-4daf-b9f5-3407667260c5")) + undo.SetJSONLDId(undoID) + + targetURI := testrig.URLMustParse(blockedAccount.InboxURI) + + signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(undo, blockingAccount.PublicKeyURI, blockingAccount.PrivateKey, targetURI) + bodyI, err := streams.Serialize(undo) + suite.NoError(err) + + bodyJson, err := json.Marshal(bodyI) + suite.NoError(err) + body := bytes.NewReader(bodyJson) + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting + ctx.Request.Header.Set("Signature", signature) + ctx.Request.Header.Set("Date", dateHeader) + ctx.Request.Header.Set("Digest", digestHeader) + ctx.Request.Header.Set("Content-Type", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.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: users.UsernameKey, + Value: blockedAccount.Username, + }, + } + + // trigger the function being tested + userModule.InboxPOSTHandler(ctx) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Empty(b) + suite.Equal(http.StatusOK, result.StatusCode) + + // the block should be undone + block, err := suite.db.GetBlock(context.Background(), blockingAccount.ID, blockedAccount.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(block) +} + +func (suite *InboxPostTestSuite) TestPostUpdate() { + updatedAccount := *suite.testAccounts["remote_account_1"] + updatedAccount.DisplayName = "updated display name!" + + // ad an emoji to the account; because we're serializing this remote + // account from our own instance, we need to cheat a bit to get the emoji + // to work properly, just for this test + testEmoji := >smodel.Emoji{} + *testEmoji = *testrig.NewTestEmojis()["yell"] + testEmoji.ImageURL = testEmoji.ImageRemoteURL // <- here's the cheat + updatedAccount.Emojis = []*gtsmodel.Emoji{testEmoji} + + asAccount, err := suite.tc.AccountToAS(context.Background(), &updatedAccount) + suite.NoError(err) + + receivingAccount := suite.testAccounts["local_account_1"] + + // create an update + update := streams.NewActivityStreamsUpdate() + + // set the appropriate actor on it + updateActor := streams.NewActivityStreamsActorProperty() + updateActor.AppendIRI(testrig.URLMustParse(updatedAccount.URI)) + update.SetActivityStreamsActor(updateActor) + + // Set the account as the 'object' property. + updateObject := streams.NewActivityStreamsObjectProperty() + updateObject.AppendActivityStreamsPerson(asAccount) + update.SetActivityStreamsObject(updateObject) + + // Set the To of the update as public + updateTo := streams.NewActivityStreamsToProperty() + updateTo.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + update.SetActivityStreamsTo(updateTo) + + // set the cc of the update to the receivingAccount + updateCC := streams.NewActivityStreamsCcProperty() + updateCC.AppendIRI(testrig.URLMustParse(receivingAccount.URI)) + update.SetActivityStreamsCc(updateCC) + + // set some random-ass ID for the activity + undoID := streams.NewJSONLDIdProperty() + undoID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/d360613a-dc8d-4563-8f0b-b6161caf0f2b")) + update.SetJSONLDId(undoID) + + targetURI := testrig.URLMustParse(receivingAccount.InboxURI) + + signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(update, updatedAccount.PublicKeyURI, updatedAccount.PrivateKey, targetURI) + bodyI, err := streams.Serialize(update) + suite.NoError(err) + + bodyJson, err := json.Marshal(bodyI) + suite.NoError(err) + body := bytes.NewReader(bodyJson) + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting + ctx.Request.Header.Set("Signature", signature) + ctx.Request.Header.Set("Date", dateHeader) + ctx.Request.Header.Set("Digest", digestHeader) + ctx.Request.Header.Set("Content-Type", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.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: users.UsernameKey, + Value: receivingAccount.Username, + }, + } + + // trigger the function being tested + userModule.InboxPOSTHandler(ctx) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Empty(b) + suite.Equal(http.StatusOK, result.StatusCode) + + // account should be changed in the database now + var dbUpdatedAccount *gtsmodel.Account + + if !testrig.WaitFor(func() bool { + // displayName should be updated + dbUpdatedAccount, _ = suite.db.GetAccountByID(context.Background(), updatedAccount.ID) + return dbUpdatedAccount.DisplayName == "updated display name!" + }) { + suite.FailNow("timed out waiting for account update") + } + + // emojis should be updated + suite.Contains(dbUpdatedAccount.EmojiIDs, testEmoji.ID) + + // account should be freshly webfingered + suite.WithinDuration(time.Now(), dbUpdatedAccount.LastWebfingeredAt, 10*time.Second) + + // everything else should be the same as it was before + suite.EqualValues(updatedAccount.Username, dbUpdatedAccount.Username) + suite.EqualValues(updatedAccount.Domain, dbUpdatedAccount.Domain) + suite.EqualValues(updatedAccount.AvatarMediaAttachmentID, dbUpdatedAccount.AvatarMediaAttachmentID) + suite.EqualValues(updatedAccount.AvatarMediaAttachment, dbUpdatedAccount.AvatarMediaAttachment) + suite.EqualValues(updatedAccount.AvatarRemoteURL, dbUpdatedAccount.AvatarRemoteURL) + suite.EqualValues(updatedAccount.HeaderMediaAttachmentID, dbUpdatedAccount.HeaderMediaAttachmentID) + suite.EqualValues(updatedAccount.HeaderMediaAttachment, dbUpdatedAccount.HeaderMediaAttachment) + suite.EqualValues(updatedAccount.HeaderRemoteURL, dbUpdatedAccount.HeaderRemoteURL) + suite.EqualValues(updatedAccount.Note, dbUpdatedAccount.Note) + suite.EqualValues(updatedAccount.Memorial, dbUpdatedAccount.Memorial) + suite.EqualValues(updatedAccount.AlsoKnownAs, dbUpdatedAccount.AlsoKnownAs) + suite.EqualValues(updatedAccount.MovedToAccountID, dbUpdatedAccount.MovedToAccountID) + suite.EqualValues(updatedAccount.Bot, dbUpdatedAccount.Bot) + suite.EqualValues(updatedAccount.Reason, dbUpdatedAccount.Reason) + suite.EqualValues(updatedAccount.Locked, dbUpdatedAccount.Locked) + suite.EqualValues(updatedAccount.Discoverable, dbUpdatedAccount.Discoverable) + suite.EqualValues(updatedAccount.Privacy, dbUpdatedAccount.Privacy) + suite.EqualValues(updatedAccount.Sensitive, dbUpdatedAccount.Sensitive) + suite.EqualValues(updatedAccount.Language, dbUpdatedAccount.Language) + suite.EqualValues(updatedAccount.URI, dbUpdatedAccount.URI) + suite.EqualValues(updatedAccount.URL, dbUpdatedAccount.URL) + suite.EqualValues(updatedAccount.InboxURI, dbUpdatedAccount.InboxURI) + suite.EqualValues(updatedAccount.OutboxURI, dbUpdatedAccount.OutboxURI) + suite.EqualValues(updatedAccount.FollowingURI, dbUpdatedAccount.FollowingURI) + suite.EqualValues(updatedAccount.FollowersURI, dbUpdatedAccount.FollowersURI) + suite.EqualValues(updatedAccount.FeaturedCollectionURI, dbUpdatedAccount.FeaturedCollectionURI) + suite.EqualValues(updatedAccount.ActorType, dbUpdatedAccount.ActorType) + suite.EqualValues(updatedAccount.PublicKey, dbUpdatedAccount.PublicKey) + suite.EqualValues(updatedAccount.PublicKeyURI, dbUpdatedAccount.PublicKeyURI) + suite.EqualValues(updatedAccount.SensitizedAt, dbUpdatedAccount.SensitizedAt) + suite.EqualValues(updatedAccount.SilencedAt, dbUpdatedAccount.SilencedAt) + suite.EqualValues(updatedAccount.SuspendedAt, dbUpdatedAccount.SuspendedAt) + suite.EqualValues(updatedAccount.HideCollections, dbUpdatedAccount.HideCollections) + suite.EqualValues(updatedAccount.SuspensionOrigin, dbUpdatedAccount.SuspensionOrigin) +} + +func (suite *InboxPostTestSuite) TestPostDelete() { + deletedAccount := *suite.testAccounts["remote_account_1"] + receivingAccount := suite.testAccounts["local_account_1"] + + // create a delete + delete := streams.NewActivityStreamsDelete() + + // set the appropriate actor on it + deleteActor := streams.NewActivityStreamsActorProperty() + deleteActor.AppendIRI(testrig.URLMustParse(deletedAccount.URI)) + delete.SetActivityStreamsActor(deleteActor) + + // Set the account iri as the 'object' property. + deleteObject := streams.NewActivityStreamsObjectProperty() + deleteObject.AppendIRI(testrig.URLMustParse(deletedAccount.URI)) + delete.SetActivityStreamsObject(deleteObject) + + // Set the To of the delete as public + deleteTo := streams.NewActivityStreamsToProperty() + deleteTo.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + delete.SetActivityStreamsTo(deleteTo) + + // set some random-ass ID for the activity + deleteID := streams.NewJSONLDIdProperty() + deleteID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/d360613a-dc8d-4563-8f0b-b6161caf0f2b")) + delete.SetJSONLDId(deleteID) + + targetURI := testrig.URLMustParse(receivingAccount.InboxURI) + + signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(delete, deletedAccount.PublicKeyURI, deletedAccount.PrivateKey, targetURI) + bodyI, err := streams.Serialize(delete) + suite.NoError(err) + + bodyJson, err := json.Marshal(bodyI) + suite.NoError(err) + body := bytes.NewReader(bodyJson) + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.NoError(processor.Start()) + userModule := users.New(processor) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting + ctx.Request.Header.Set("Signature", signature) + ctx.Request.Header.Set("Date", dateHeader) + ctx.Request.Header.Set("Digest", digestHeader) + ctx.Request.Header.Set("Content-Type", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.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: users.UsernameKey, + Value: receivingAccount.Username, + }, + } + + // trigger the function being tested + userModule.InboxPOSTHandler(ctx) + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Empty(b) + suite.Equal(http.StatusOK, result.StatusCode) + + if !testrig.WaitFor(func() bool { + // local account 2 blocked foss_satan, that block should be gone now + testBlock := suite.testBlocks["local_account_2_block_remote_account_1"] + dbBlock := >smodel.Block{} + err = suite.db.GetByID(ctx, testBlock.ID, dbBlock) + return suite.ErrorIs(err, db.ErrNoEntries) + }) { + suite.FailNow("timed out waiting for block to be removed") + } + + // no statuses from foss satan should be left in the database + dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, false, "", "", false, false, false) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Empty(dbStatuses) + + dbAccount, err := suite.db.GetAccountByID(ctx, deletedAccount.ID) + suite.NoError(err) + + suite.Empty(dbAccount.Note) + suite.Empty(dbAccount.DisplayName) + suite.Empty(dbAccount.AvatarMediaAttachmentID) + suite.Empty(dbAccount.AvatarRemoteURL) + suite.Empty(dbAccount.HeaderMediaAttachmentID) + suite.Empty(dbAccount.HeaderRemoteURL) + suite.Empty(dbAccount.Reason) + suite.Empty(dbAccount.Fields) + suite.True(*dbAccount.HideCollections) + suite.False(*dbAccount.Discoverable) + suite.WithinDuration(time.Now(), dbAccount.SuspendedAt, 30*time.Second) + suite.Equal(dbAccount.ID, dbAccount.SuspensionOrigin) +} + +func TestInboxPostTestSuite(t *testing.T) { + suite.Run(t, &InboxPostTestSuite{}) +} diff --git a/internal/api/activitypub/users/outboxget.go b/internal/api/activitypub/users/outboxget.go new file mode 100644 index 000000000..8e5d1a751 --- /dev/null +++ b/internal/api/activitypub/users/outboxget.go @@ -0,0 +1,145 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// 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) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + var page bool + if pageString := c.Query(PageKey); pageString != "" { + i, err := strconv.ParseBool(pageString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", PageKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + page = i + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + resp, errWithCode := m.processor.GetFediOutbox(apiutil.TransferSignatureContext(c), requestedUsername, page, maxID, minID, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/outboxget_test.go b/internal/api/activitypub/users/outboxget_test.go new file mode 100644 index 000000000..cd0518350 --- /dev/null +++ b/internal/api/activitypub/users/outboxget_test.go @@ -0,0 +1,216 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users_test + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "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"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: targetAccount.Username, + }, + } + + // trigger the function being tested + suite.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"] + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.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"] + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true&max_id=01F8MHAMCHF6Y650WCRSCP4WMY", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.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/activitypub/users/publickeyget.go b/internal/api/activitypub/users/publickeyget.go new file mode 100644 index 000000000..d57aa2f9d --- /dev/null +++ b/internal/api/activitypub/users/publickeyget.go @@ -0,0 +1,71 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// PublicKeyGETHandler should be served at eg https://example.org/users/:username/main-key. +// +// The goal here is to return a MINIMAL activitypub representation of an account +// in the form of a vocab.ActivityStreamsPerson. The account will only contain the id, +// public key, username, and type of the account. +func (m *Module) PublicKeyGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.GetFediUser(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/repliesget.go b/internal/api/activitypub/users/repliesget.go new file mode 100644 index 000000000..253166446 --- /dev/null +++ b/internal/api/activitypub/users/repliesget.go @@ -0,0 +1,166 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// StatusRepliesGETHandler swagger:operation GET /users/{username}/statuses/{status}/replies s2sRepliesGet +// +// Get the replies collection for a status. +// +// 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: status +// type: string +// description: ID of the status. +// in: path +// required: true +// - +// name: page +// type: boolean +// description: Return response as a CollectionPage. +// in: query +// default: false +// - +// name: only_other_accounts +// type: boolean +// description: Return replies only from accounts other than the status owner. +// in: query +// default: false +// - +// name: min_id +// type: string +// description: Minimum 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) StatusRepliesGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + // status IDs on our instance are always uppercase + requestedStatusID := strings.ToUpper(c.Param(StatusIDKey)) + if requestedStatusID == "" { + err := errors.New("no status id specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the status + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername+"/statuses/"+requestedStatusID) + return + } + + var page bool + if pageString := c.Query(PageKey); pageString != "" { + i, err := strconv.ParseBool(pageString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", PageKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + page = i + } + + onlyOtherAccounts := false + onlyOtherAccountsString := c.Query(OnlyOtherAccountsKey) + if onlyOtherAccountsString != "" { + i, err := strconv.ParseBool(onlyOtherAccountsString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", OnlyOtherAccountsKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + onlyOtherAccounts = i + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + resp, errWithCode := m.processor.GetFediStatusReplies(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/repliesget_test.go b/internal/api/activitypub/users/repliesget_test.go new file mode 100644 index 000000000..3d8e0b12a --- /dev/null +++ b/internal/api/activitypub/users/repliesget_test.go @@ -0,0 +1,239 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users_test + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type RepliesGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *RepliesGetTestSuite) TestGetReplies() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.StatusIDKey, + Value: targetStatus.ID, + }, + } + + // trigger the function being tested + suite.userModule.StatusRepliesGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","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"}`, string(b)) + + // should be a Collection + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + _, ok := t.(vocab.ActivityStreamsCollection) + assert.True(suite.T(), ok) +} + +func (suite *RepliesGetTestSuite) TestGetRepliesNext() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies_next"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?only_other_accounts=false&page=true", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.StatusIDKey, + Value: targetStatus.ID, + }, + } + + // trigger the function being tested + userModule.StatusRepliesGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true\u0026only_other_accounts=false","items":"http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true\u0026min_id=01FF25D5Q0DH7CHD57CTRS6WK0","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"}`, string(b)) + + // should be a Collection + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + page, ok := t.(vocab.ActivityStreamsCollectionPage) + assert.True(suite.T(), ok) + + assert.Equal(suite.T(), page.GetActivityStreamsItems().Len(), 1) +} + +func (suite *RepliesGetTestSuite) TestGetRepliesLast() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies_last"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?only_other_accounts=false&page=true&min_id=01FF25D5Q0DH7CHD57CTRS6WK0", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.StatusIDKey, + Value: targetStatus.ID, + }, + } + + // trigger the function being tested + userModule.StatusRepliesGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + fmt.Println(string(b)) + assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true\u0026only_other_accounts=false\u0026min_id=01FF25D5Q0DH7CHD57CTRS6WK0","items":[],"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"}`, string(b)) + + // should be a Collection + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + page, ok := t.(vocab.ActivityStreamsCollectionPage) + assert.True(suite.T(), ok) + + assert.Equal(suite.T(), page.GetActivityStreamsItems().Len(), 0) +} + +func TestRepliesGetTestSuite(t *testing.T) { + suite.Run(t, new(RepliesGetTestSuite)) +} diff --git a/internal/api/activitypub/users/statusget.go b/internal/api/activitypub/users/statusget.go new file mode 100644 index 000000000..a72f8fd16 --- /dev/null +++ b/internal/api/activitypub/users/statusget.go @@ -0,0 +1,75 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// StatusGETHandler serves the target status as an activitystreams NOTE so that other AP servers can parse it. +func (m *Module) StatusGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + // status IDs on our instance are always uppercase + requestedStatusID := strings.ToUpper(c.Param(StatusIDKey)) + if requestedStatusID == "" { + err := errors.New("no status id specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the status + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername+"/statuses/"+requestedStatusID) + return + } + + resp, errWithCode := m.processor.GetFediStatus(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/statusget_test.go b/internal/api/activitypub/users/statusget_test.go new file mode 100644 index 000000000..37d311e7b --- /dev/null +++ b/internal/api/activitypub/users/statusget_test.go @@ -0,0 +1,162 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users_test + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *StatusGetTestSuite) TestGetStatus() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.StatusIDKey, + Value: targetStatus.ID, + }, + } + + // trigger the function being tested + suite.userModule.StatusGETHandler(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) + + // should be a Note + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + note, ok := t.(vocab.ActivityStreamsNote) + suite.True(ok) + + // convert note to status + a, err := suite.tc.ASStatusToStatus(context.Background(), note) + suite.NoError(err) + suite.EqualValues(targetStatus.Content, a.Content) +} + +func (suite *StatusGetTestSuite) TestGetStatusLowercase() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_lowercase"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, strings.ToLower(targetStatus.URI), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: strings.ToLower(targetAccount.Username), + }, + gin.Param{ + Key: users.StatusIDKey, + Value: strings.ToLower(targetStatus.ID), + }, + } + + // trigger the function being tested + suite.userModule.StatusGETHandler(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) + + // should be a Note + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + note, ok := t.(vocab.ActivityStreamsNote) + suite.True(ok) + + // convert note to status + a, err := suite.tc.ASStatusToStatus(context.Background(), note) + suite.NoError(err) + suite.EqualValues(targetStatus.Content, a.Content) +} + +func TestStatusGetTestSuite(t *testing.T) { + suite.Run(t, new(StatusGetTestSuite)) +} diff --git a/internal/api/activitypub/users/user.go b/internal/api/activitypub/users/user.go new file mode 100644 index 000000000..d2e02fb08 --- /dev/null +++ b/internal/api/activitypub/users/user.go @@ -0,0 +1,80 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +const ( + // UsernameKey is for account usernames. + UsernameKey = "username" + // StatusIDKey is for status IDs + StatusIDKey = "status" + // OnlyOtherAccountsKey is for filtering status responses. + 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" + + // BasePath is the base path for serving AP 'users' requests, minus the 'users' prefix. + BasePath = "/:" + UsernameKey + // PublicKeyPath is a path to a user's public key, for serving bare minimum AP representations. + PublicKeyPath = BasePath + "/" + uris.PublicKeyPath + // InboxPath is for serving POST requests to a user's inbox with the given username key. + InboxPath = BasePath + "/" + uris.InboxPath + // OutboxPath is for serving GET requests to a user's outbox with the given username key. + OutboxPath = BasePath + "/" + uris.OutboxPath + // FollowersPath is for serving GET request's to a user's followers list, with the given username key. + FollowersPath = BasePath + "/" + uris.FollowersPath + // FollowingPath is for serving GET request's to a user's following list, with the given username key. + FollowingPath = BasePath + "/" + uris.FollowingPath + // StatusPath is for serving GET requests to a particular status by a user, with the given username key and status ID + StatusPath = BasePath + "/" + uris.StatusesPath + "/:" + StatusIDKey + // StatusRepliesPath is for serving the replies collection of a status. + StatusRepliesPath = StatusPath + "/replies" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.UsersGETHandler) + attachHandler(http.MethodPost, InboxPath, m.InboxPOSTHandler) + attachHandler(http.MethodGet, FollowersPath, m.FollowersGETHandler) + attachHandler(http.MethodGet, FollowingPath, m.FollowingGETHandler) + attachHandler(http.MethodGet, StatusPath, m.StatusGETHandler) + attachHandler(http.MethodGet, PublicKeyPath, m.PublicKeyGETHandler) + attachHandler(http.MethodGet, StatusRepliesPath, m.StatusRepliesGETHandler) + attachHandler(http.MethodGet, OutboxPath, m.OutboxGETHandler) +} diff --git a/internal/api/activitypub/users/user_test.go b/internal/api/activitypub/users/user_test.go new file mode 100644 index 000000000..0f08366f0 --- /dev/null +++ b/internal/api/activitypub/users/user_test.go @@ -0,0 +1,103 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users_test + +import ( + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + tc typeutils.TypeConverter + mediaManager media.Manager + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *storage.Driver + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testBlocks map[string]*gtsmodel.Block + + // module being tested + userModule *users.Module + + signatureCheck gin.HandlerFunc +} + +func (suite *UserStandardTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() + suite.testBlocks = testrig.NewTestBlocks() +} + +func (suite *UserStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + suite.db = testrig.NewTestDB() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.userModule = users.New(suite.processor) + testrig.StandardDBSetup(suite.db, suite.testAccounts) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + suite.signatureCheck = middleware.SignatureCheck(suite.db.IsURIBlocked) + + suite.NoError(suite.processor.Start()) +} + +func (suite *UserStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/api/activitypub/users/userget.go b/internal/api/activitypub/users/userget.go new file mode 100644 index 000000000..51b0ebd72 --- /dev/null +++ b/internal/api/activitypub/users/userget.go @@ -0,0 +1,75 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// UsersGETHandler should be served at https://example.org/users/:username. +// +// The goal here is to return the activitypub representation of an account +// in the form of a vocab.ActivityStreamsPerson. This should only be served +// to REMOTE SERVERS that present a valid signature on the GET request, on +// behalf of a user, otherwise we risk leaking information about users publicly. +// +// And of course, the request should be refused if the account or server making the +// request is blocked. +func (m *Module) UsersGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.GetFediUser(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/userget_test.go b/internal/api/activitypub/users/userget_test.go new file mode 100644 index 000000000..511634fc7 --- /dev/null +++ b/internal/api/activitypub/users/userget_test.go @@ -0,0 +1,177 @@ +/* + GoToSocial + Copyright (C) 2021-2022 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 users_test + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *UserGetTestSuite) TestGetUser() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_zork"] + targetAccount := suite.testAccounts["local_account_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.URI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: targetAccount.Username, + }, + } + + // trigger the function being tested + suite.userModule.UsersGETHandler(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) + + // should be a Person + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + person, ok := t.(vocab.ActivityStreamsPerson) + suite.True(ok) + + // convert person to account + // since this account is already known, we should get a pretty full model of it from the conversion + a, err := suite.tc.ASRepresentationToAccount(context.Background(), person, "", false) + suite.NoError(err) + suite.EqualValues(targetAccount.Username, a.Username) +} + +// TestGetUserPublicKeyDeleted checks whether the public key of a deleted account can still be dereferenced. +// This is needed by remote instances for authenticating delete requests and stuff like that. +func (suite *UserGetTestSuite) TestGetUserPublicKeyDeleted() { + userModule := users.New(suite.processor) + targetAccount := suite.testAccounts["local_account_1"] + + // first delete the account, as though zork had deleted himself + authed := &oauth.Auth{ + Application: suite.testApplications["local_account_1"], + User: suite.testUsers["local_account_1"], + Account: suite.testAccounts["local_account_1"], + } + suite.processor.AccountDeleteLocal(context.Background(), authed, &apimodel.AccountDeleteRequest{ + Password: "password", + DeleteOriginID: targetAccount.ID, + }) + + // wait for the account delete to be processed + if !testrig.WaitFor(func() bool { + a, _ := suite.db.GetAccountByID(context.Background(), targetAccount.ID) + return !a.SuspendedAt.IsZero() + }) { + suite.FailNow("delete of account timed out") + } + + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_zork_public_key"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.PublicKeyURI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + 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.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: users.UsernameKey, + Value: targetAccount.Username, + }, + } + + // trigger the function being tested + userModule.UsersGETHandler(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) + + // should be a Person + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + person, ok := t.(vocab.ActivityStreamsPerson) + suite.True(ok) + + // convert person to account + a, err := suite.tc.ASRepresentationToAccount(context.Background(), person, "", false) + suite.NoError(err) + suite.EqualValues(targetAccount.Username, a.Username) +} + +func TestUserGetTestSuite(t *testing.T) { + suite.Run(t, new(UserGetTestSuite)) +} |