diff options
author | 2024-07-29 11:26:31 -0700 | |
---|---|---|
committer | 2024-07-29 19:26:31 +0100 | |
commit | a237e2b295fee71bdf7266520b0b6e0fb79b565c (patch) | |
tree | c522adc47019584b60de9420595505820635bb11 /internal/api | |
parent | [bugfix] take into account rotation when generating thumbnail (#3147) (diff) | |
download | gotosocial-a237e2b295fee71bdf7266520b0b6e0fb79b565c.tar.xz |
[feature] Implement following hashtags (#3141)
* Implement followed tags API
* Insert statuses with followed tags into home timelines
* Test following and unfollowing tags
* Correct Swagger path params
* Trim conversation caches
* Migration for followed_tags table
* Followed tag caches and DB implementation
* Lint and tests
* Add missing tag info endpoint, reorganize tag API
* Unwrap boosts when timelining based on tags
* Apply visibility filters to tag followers
* Address review comments
Diffstat (limited to 'internal/api')
-rw-r--r-- | internal/api/client.go | 10 | ||||
-rw-r--r-- | internal/api/client/featuredtags/get.go | 2 | ||||
-rw-r--r-- | internal/api/client/followedtags/followedtags.go | 43 | ||||
-rw-r--r-- | internal/api/client/followedtags/followedtags_test.go | 104 | ||||
-rw-r--r-- | internal/api/client/followedtags/get.go | 139 | ||||
-rw-r--r-- | internal/api/client/followedtags/get_test.go | 125 | ||||
-rw-r--r-- | internal/api/client/tags/follow.go | 92 | ||||
-rw-r--r-- | internal/api/client/tags/follow_test.go | 82 | ||||
-rw-r--r-- | internal/api/client/tags/get.go | 89 | ||||
-rw-r--r-- | internal/api/client/tags/get_test.go | 93 | ||||
-rw-r--r-- | internal/api/client/tags/tags.go | 49 | ||||
-rw-r--r-- | internal/api/client/tags/tags_test.go | 179 | ||||
-rw-r--r-- | internal/api/client/tags/unfollow.go | 94 | ||||
-rw-r--r-- | internal/api/client/tags/unfollow_test.go | 82 | ||||
-rw-r--r-- | internal/api/model/tag.go | 3 |
15 files changed, 1184 insertions, 2 deletions
diff --git a/internal/api/client.go b/internal/api/client.go index b8b226804..18ab9b50b 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -32,6 +32,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags" filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1" filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + "github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags" "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests" "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" "github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies" @@ -46,6 +47,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/search" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/api/client/streaming" + "github.com/superseriousbusiness/gotosocial/internal/api/client/tags" "github.com/superseriousbusiness/gotosocial/internal/api/client/timelines" "github.com/superseriousbusiness/gotosocial/internal/api/client/user" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -59,7 +61,7 @@ type Client struct { processor *processing.Processor db db.DB - accounts *accounts.Module // api/v1/accounts + accounts *accounts.Module // api/v1/accounts, api/v1/profile admin *admin.Module // api/v1/admin apps *apps.Module // api/v1/apps blocks *blocks.Module // api/v1/blocks @@ -71,6 +73,7 @@ type Client struct { filtersV1 *filtersV1.Module // api/v1/filters filtersV2 *filtersV2.Module // api/v2/filters followRequests *followrequests.Module // api/v1/follow_requests + followedTags *followedtags.Module // api/v1/followed_tags instance *instance.Module // api/v1/instance interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies lists *lists.Module // api/v1/lists @@ -84,6 +87,7 @@ type Client struct { search *search.Module // api/v1/search, api/v2/search statuses *statuses.Module // api/v1/statuses streaming *streaming.Module // api/v1/streaming + tags *tags.Module // api/v1/tags timelines *timelines.Module // api/v1/timelines user *user.Module // api/v1/user } @@ -117,6 +121,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) { c.filtersV1.Route(h) c.filtersV2.Route(h) c.followRequests.Route(h) + c.followedTags.Route(h) c.instance.Route(h) c.interactionPolicies.Route(h) c.lists.Route(h) @@ -130,6 +135,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) { c.search.Route(h) c.statuses.Route(h) c.streaming.Route(h) + c.tags.Route(h) c.timelines.Route(h) c.user.Route(h) } @@ -151,6 +157,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client { filtersV1: filtersV1.New(p), filtersV2: filtersV2.New(p), followRequests: followrequests.New(p), + followedTags: followedtags.New(p), instance: instance.New(p), interactionPolicies: interactionpolicies.New(p), lists: lists.New(p), @@ -164,6 +171,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client { search: search.New(p), statuses: statuses.New(p), streaming: streaming.New(p, time.Second*30, 4096), + tags: tags.New(p), timelines: timelines.New(p), user: user.New(p), } diff --git a/internal/api/client/featuredtags/get.go b/internal/api/client/featuredtags/get.go index c1ee7ca2c..de47f7ee2 100644 --- a/internal/api/client/featuredtags/get.go +++ b/internal/api/client/featuredtags/get.go @@ -34,7 +34,7 @@ import ( // // --- // tags: -// - featured_tags +// - tags // // produces: // - application/json diff --git a/internal/api/client/followedtags/followedtags.go b/internal/api/client/followedtags/followedtags.go new file mode 100644 index 000000000..27fca918d --- /dev/null +++ b/internal/api/client/followedtags/followedtags.go @@ -0,0 +1,43 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package followedtags + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + BasePath = "/v1/followed_tags" +) + +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.FollowedTagsGETHandler) +} diff --git a/internal/api/client/followedtags/followedtags_test.go b/internal/api/client/followedtags/followedtags_test.go new file mode 100644 index 000000000..883ab033b --- /dev/null +++ b/internal/api/client/followedtags/followedtags_test.go @@ -0,0 +1,104 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package followedtags_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags" + "github.com/superseriousbusiness/gotosocial/internal/config" + "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/processing" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type FollowedTagsTestSuite struct { + suite.Suite + db db.DB + storage *storage.Driver + mediaManager *media.Manager + federator *federation.Federator + processor *processing.Processor + emailSender email.Sender + sentEmails map[string]string + state state.State + + // 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 + testTags map[string]*gtsmodel.Tag + + // module being tested + followedTagsModule *followedtags.Module +} + +func (suite *FollowedTagsTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testTags = testrig.NewTestTags() +} + +func (suite *FollowedTagsTestSuite) SetupTest() { + suite.state.Caches.Init() + testrig.StartNoopWorkers(&suite.state) + + testrig.InitTestConfig() + config.Config(func(cfg *config.Configuration) { + cfg.WebAssetBaseDir = "../../../../web/assets/" + cfg.WebTemplateBaseDir = "../../../../web/templates/" + }) + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.state.DB = suite.db + suite.storage = testrig.NewInMemoryStorage() + suite.state.Storage = suite.storage + + suite.mediaManager = testrig.NewTestMediaManager(&suite.state) + suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.followedTagsModule = followedtags.New(suite.processor) + + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *FollowedTagsTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) + testrig.StopWorkers(&suite.state) +} + +func TestFollowedTagsTestSuite(t *testing.T) { + suite.Run(t, new(FollowedTagsTestSuite)) +} diff --git a/internal/api/client/followedtags/get.go b/internal/api/client/followedtags/get.go new file mode 100644 index 000000000..68e4ffb5f --- /dev/null +++ b/internal/api/client/followedtags/get.go @@ -0,0 +1,139 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package followedtags + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// FollowedTagsGETHandler swagger:operation GET /api/v1/followed_tags getFollowedTags +// +// Get an array of all hashtags that you currently follow. +// +// --- +// tags: +// - tags +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:follows +// +// parameters: +// - +// name: max_id +// type: string +// description: >- +// Return only followed tags *OLDER* than the given max ID. +// The followed tag with the specified ID will not be included in the response. +// NOTE: the ID is of the internal followed tag, NOT a tag name. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: >- +// Return only followed tags *NEWER* than the given since ID. +// The followed tag with the specified ID will not be included in the response. +// NOTE: the ID is of the internal followed tag, NOT a tag name. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only followed tags *IMMEDIATELY NEWER* than the given min ID. +// The followed tag with the specified ID will not be included in the response. +// NOTE: the ID is of the internal followed tag, NOT a tag name. +// in: query +// required: false +// - +// name: limit +// type: integer +// description: Number of followed tags to return. +// default: 100 +// minimum: 1 +// maximum: 200 +// in: query +// required: false +// +// responses: +// '200': +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// schema: +// type: array +// items: +// "$ref": "#/definitions/tag" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FollowedTagsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, + 1, // min limit + 200, // max limit + 100, // default limit + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Tags().Followed( + c.Request.Context(), + authed.Account.ID, + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/client/followedtags/get_test.go b/internal/api/client/followedtags/get_test.go new file mode 100644 index 000000000..bd82c7037 --- /dev/null +++ b/internal/api/client/followedtags/get_test.go @@ -0,0 +1,125 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package followedtags_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FollowedTagsTestSuite) getFollowedTags( + accountFixtureName string, + expectedHTTPStatus int, + expectedBody string, +) ([]apimodel.Tag, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[accountFixtureName])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+followedtags.BasePath, nil) + ctx.Request.Header.Set("accept", "application/json") + + // trigger the handler + suite.followedTagsModule.FollowedTagsGETHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := []apimodel.Tag{} + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + + return resp, nil +} + +// Test that we can list a user's followed tags. +func (suite *FollowedTagsTestSuite) TestGet() { + accountFixtureName := "local_account_2" + testAccount := suite.testAccounts[accountFixtureName] + testTag := suite.testTags["welcome"] + + // Follow an existing tag. + if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + followedTags, err := suite.getFollowedTags(accountFixtureName, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + if suite.Len(followedTags, 1) { + followedTag := followedTags[0] + suite.Equal(testTag.Name, followedTag.Name) + if suite.NotNil(followedTag.Following) { + suite.True(*followedTag.Following) + } + } +} + +// Test that we can list a user's followed tags even if they don't have any. +func (suite *FollowedTagsTestSuite) TestGetEmpty() { + accountFixtureName := "local_account_1" + + followedTags, err := suite.getFollowedTags(accountFixtureName, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(followedTags, 0) +} diff --git a/internal/api/client/tags/follow.go b/internal/api/client/tags/follow.go new file mode 100644 index 000000000..2952996b1 --- /dev/null +++ b/internal/api/client/tags/follow.go @@ -0,0 +1,92 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tags + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FollowTagPOSTHandler swagger:operation POST /api/v1/tags/{tag_name}/follow followTag +// +// Follow a hashtag. +// +// Idempotent: if you are already following the tag, this call will still succeed. +// +// --- +// tags: +// - tags +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - write:follows +// +// parameters: +// - +// name: tag_name +// type: string +// description: Name of the tag (no leading `#`) +// in: path +// required: true +// +// responses: +// '200': +// description: "Info about the tag." +// schema: +// "$ref": "#/definitions/tag" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '500': +// description: internal server error +func (m *Module) FollowTagPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiTag, errWithCode := m.processor.Tags().Follow(c.Request.Context(), authed.Account, name) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiTag) +} diff --git a/internal/api/client/tags/follow_test.go b/internal/api/client/tags/follow_test.go new file mode 100644 index 000000000..d3b08cf5c --- /dev/null +++ b/internal/api/client/tags/follow_test.go @@ -0,0 +1,82 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tags_test + +import ( + "context" + "net/http" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/tags" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +) + +func (suite *TagsTestSuite) follow( + accountFixtureName string, + tagName string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.Tag, error) { + return suite.tagAction( + accountFixtureName, + tagName, + http.MethodPost, + tags.FollowPath, + suite.tagsModule.FollowTagPOSTHandler, + expectedHTTPStatus, + expectedBody, + ) +} + +// Follow a tag we don't already follow. +func (suite *TagsTestSuite) TestFollow() { + accountFixtureName := "local_account_2" + testTag := suite.testTags["welcome"] + + apiTag, err := suite.follow(accountFixtureName, testTag.Name, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(testTag.Name, apiTag.Name) + if suite.NotNil(apiTag.Following) { + suite.True(*apiTag.Following) + } +} + +// When we follow a tag already followed by the account, it should succeed. +func (suite *TagsTestSuite) TestFollowIdempotent() { + accountFixtureName := "local_account_2" + testAccount := suite.testAccounts[accountFixtureName] + testTag := suite.testTags["welcome"] + + // Setup: follow an existing tag. + if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Follow it again through the API. + apiTag, err := suite.follow(accountFixtureName, testTag.Name, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(testTag.Name, apiTag.Name) + if suite.NotNil(apiTag.Following) { + suite.True(*apiTag.Following) + } +} diff --git a/internal/api/client/tags/get.go b/internal/api/client/tags/get.go new file mode 100644 index 000000000..b61b7cc65 --- /dev/null +++ b/internal/api/client/tags/get.go @@ -0,0 +1,89 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tags + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// TagGETHandler swagger:operation GET /api/v1/tags/{tag_name} getTag +// +// Get details for a hashtag, including whether you currently follow it. +// +// If the tag does not exist, this method will not create it in the database. +// +// --- +// tags: +// - tags +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:follows +// +// parameters: +// - +// name: tag_name +// type: string +// description: Name of the tag (no leading `#`) +// in: path +// required: true +// +// responses: +// '200': +// description: "Info about the tag." +// schema: +// "$ref": "#/definitions/tag" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) TagGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiTag, errWithCode := m.processor.Tags().Get(c.Request.Context(), authed.Account, name) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiTag) +} diff --git a/internal/api/client/tags/get_test.go b/internal/api/client/tags/get_test.go new file mode 100644 index 000000000..fa31bce7d --- /dev/null +++ b/internal/api/client/tags/get_test.go @@ -0,0 +1,93 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tags_test + +import ( + "context" + "net/http" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/tags" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +) + +// tagAction follows or unfollows a tag. +func (suite *TagsTestSuite) get( + accountFixtureName string, + tagName string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.Tag, error) { + return suite.tagAction( + accountFixtureName, + tagName, + http.MethodGet, + tags.TagPath, + suite.tagsModule.TagGETHandler, + expectedHTTPStatus, + expectedBody, + ) +} + +// Get a tag followed by the account. +func (suite *TagsTestSuite) TestGetFollowed() { + accountFixtureName := "local_account_2" + testAccount := suite.testAccounts[accountFixtureName] + testTag := suite.testTags["welcome"] + + // Setup: follow an existing tag. + if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Get it through the API. + apiTag, err := suite.get(accountFixtureName, testTag.Name, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(testTag.Name, apiTag.Name) + if suite.NotNil(apiTag.Following) { + suite.True(*apiTag.Following) + } +} + +// Get a tag not followed by the account. +func (suite *TagsTestSuite) TestGetUnfollowed() { + accountFixtureName := "local_account_2" + testTag := suite.testTags["Hashtag"] + + apiTag, err := suite.get(accountFixtureName, testTag.Name, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(testTag.Name, apiTag.Name) + if suite.NotNil(apiTag.Following) { + suite.False(*apiTag.Following) + } +} + +// Get a tag that does not exist, which should result in a 404. +func (suite *TagsTestSuite) TestGetNotFound() { + accountFixtureName := "local_account_2" + + _, err := suite.get(accountFixtureName, "THIS_TAG_DOES_NOT_EXIST", http.StatusNotFound, "") + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/client/tags/tags.go b/internal/api/client/tags/tags.go new file mode 100644 index 000000000..281859547 --- /dev/null +++ b/internal/api/client/tags/tags.go @@ -0,0 +1,49 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tags + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + BasePath = "/v1/tags" + TagPath = BasePath + "/:" + apiutil.TagNameKey + FollowPath = TagPath + "/follow" + UnfollowPath = TagPath + "/unfollow" +) + +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, TagPath, m.TagGETHandler) + attachHandler(http.MethodPost, FollowPath, m.FollowTagPOSTHandler) + attachHandler(http.MethodPost, UnfollowPath, m.UnfollowTagPOSTHandler) +} diff --git a/internal/api/client/tags/tags_test.go b/internal/api/client/tags/tags_test.go new file mode 100644 index 000000000..79c708b10 --- /dev/null +++ b/internal/api/client/tags/tags_test.go @@ -0,0 +1,179 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tags_test + +import ( + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/tags" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type TagsTestSuite struct { + suite.Suite + db db.DB + storage *storage.Driver + mediaManager *media.Manager + federator *federation.Federator + processor *processing.Processor + emailSender email.Sender + sentEmails map[string]string + state state.State + + // 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 + testTags map[string]*gtsmodel.Tag + + // module being tested + tagsModule *tags.Module +} + +func (suite *TagsTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testTags = testrig.NewTestTags() +} + +func (suite *TagsTestSuite) SetupTest() { + suite.state.Caches.Init() + testrig.StartNoopWorkers(&suite.state) + + testrig.InitTestConfig() + config.Config(func(cfg *config.Configuration) { + cfg.WebAssetBaseDir = "../../../../web/assets/" + cfg.WebTemplateBaseDir = "../../../../web/templates/" + }) + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.state.DB = suite.db + suite.storage = testrig.NewInMemoryStorage() + suite.state.Storage = suite.storage + + suite.mediaManager = testrig.NewTestMediaManager(&suite.state) + suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.tagsModule = tags.New(suite.processor) + + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *TagsTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) + testrig.StopWorkers(&suite.state) +} + +// tagAction gets, follows, or unfollows a tag, returning the tag. +func (suite *TagsTestSuite) tagAction( + accountFixtureName string, + tagName string, + method string, + path string, + handler func(c *gin.Context), + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.Tag, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[accountFixtureName])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName]) + + // create the request + url := config.GetProtocol() + "://" + config.GetHost() + "/api/" + path + ctx.Request = httptest.NewRequest( + method, + strings.Replace(url, ":tag_name", tagName, 1), + nil, + ) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("tag_name", tagName) + + // trigger the handler + handler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := &apimodel.Tag{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func TestTagsTestSuite(t *testing.T) { + suite.Run(t, new(TagsTestSuite)) +} diff --git a/internal/api/client/tags/unfollow.go b/internal/api/client/tags/unfollow.go new file mode 100644 index 000000000..3166e08ed --- /dev/null +++ b/internal/api/client/tags/unfollow.go @@ -0,0 +1,94 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tags + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// UnfollowTagPOSTHandler swagger:operation POST /api/v1/tags/{tag_name}/unfollow unfollowTag +// +// Unfollow a hashtag. +// +// Idempotent: if you are not following the tag, this call will still succeed. +// +// --- +// tags: +// - tags +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - write:follows +// +// parameters: +// - +// name: tag_name +// type: string +// description: Name of the tag (no leading `#`) +// in: path +// required: true +// +// responses: +// '200': +// description: "Info about the tag." +// schema: +// "$ref": "#/definitions/tag" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: unauthorized +// '500': +// description: internal server error +func (m *Module) UnfollowTagPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiTag, errWithCode := m.processor.Tags().Unfollow(c.Request.Context(), authed.Account, name) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiTag) +} diff --git a/internal/api/client/tags/unfollow_test.go b/internal/api/client/tags/unfollow_test.go new file mode 100644 index 000000000..51bc34797 --- /dev/null +++ b/internal/api/client/tags/unfollow_test.go @@ -0,0 +1,82 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tags_test + +import ( + "context" + "net/http" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/tags" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +) + +func (suite *TagsTestSuite) unfollow( + accountFixtureName string, + tagName string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.Tag, error) { + return suite.tagAction( + accountFixtureName, + tagName, + http.MethodPost, + tags.UnfollowPath, + suite.tagsModule.UnfollowTagPOSTHandler, + expectedHTTPStatus, + expectedBody, + ) +} + +// Unfollow a tag that we follow. +func (suite *TagsTestSuite) TestUnfollow() { + accountFixtureName := "local_account_2" + testAccount := suite.testAccounts[accountFixtureName] + testTag := suite.testTags["welcome"] + + // Setup: follow an existing tag. + if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Unfollow it through the API. + apiTag, err := suite.unfollow(accountFixtureName, testTag.Name, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(testTag.Name, apiTag.Name) + if suite.NotNil(apiTag.Following) { + suite.False(*apiTag.Following) + } +} + +// When we unfollow a tag not followed by the account, it should succeed. +func (suite *TagsTestSuite) TestUnfollowIdempotent() { + accountFixtureName := "local_account_2" + testTag := suite.testTags["Hashtag"] + + apiTag, err := suite.unfollow(accountFixtureName, testTag.Name, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(testTag.Name, apiTag.Name) + if suite.NotNil(apiTag.Following) { + suite.False(*apiTag.Following) + } +} diff --git a/internal/api/model/tag.go b/internal/api/model/tag.go index ebc12e2d4..4f81bac6f 100644 --- a/internal/api/model/tag.go +++ b/internal/api/model/tag.go @@ -31,4 +31,7 @@ type Tag struct { // Currently just a stub, if provided will always be an empty array. // example: [] History *[]any `json:"history,omitempty"` + // Following is true if the user is following this tag, false if they're not, + // and not present if there is no currently authenticated user. + Following *bool `json:"following,omitempty"` } |