diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 22ce536fd..07ff289d7 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2916,6 +2916,12 @@ definitions: x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users tag: properties: + following: + description: |- + 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. + type: boolean + x-go-name: Following history: description: |- History of this hashtag's usage. @@ -6439,7 +6445,7 @@ paths: - read:accounts summary: Get an array of all hashtags that you currently have featured on your profile. tags: - - featured_tags + - tags /api/v1/filters: get: operationId: filtersV1Get @@ -6834,6 +6840,58 @@ paths: summary: Reject/deny follow request from the given account ID. tags: - follow_requests + /api/v1/followed_tags: + get: + operationId: getFollowedTags + parameters: + - 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 + name: max_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: since_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 + name: min_id + type: string + - default: 100 + description: Number of followed tags to return. + in: query + maximum: 200 + minimum: 1 + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: "" + headers: + Link: + description: Links to the next and previous queries. + type: string + schema: + items: + $ref: '#/definitions/tag' + type: array + "400": + description: bad request + "401": + description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - read:follows + summary: Get an array of all hashtags that you currently follow. + tags: + - tags /api/v1/instance: get: operationId: instanceGetV1 @@ -9072,6 +9130,103 @@ paths: summary: Initiate a websocket connection for live streaming of statuses and notifications. tags: - streaming + /api/v1/tags/{tag_name}: + get: + description: If the tag does not exist, this method will not create it in the database. + operationId: getTag + parameters: + - description: Name of the tag (no leading `#`) + in: path + name: tag_name + required: true + type: string + produces: + - application/json + 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 + security: + - OAuth2 Bearer: + - read:follows + summary: Get details for a hashtag, including whether you currently follow it. + tags: + - tags + /api/v1/tags/{tag_name}/follow: + post: + description: 'Idempotent: if you are already following the tag, this call will still succeed.' + operationId: followTag + parameters: + - description: Name of the tag (no leading `#`) + in: path + name: tag_name + required: true + type: string + produces: + - application/json + 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 + security: + - OAuth2 Bearer: + - write:follows + summary: Follow a hashtag. + tags: + - tags + /api/v1/tags/{tag_name}/unfollow: + post: + description: 'Idempotent: if you are not following the tag, this call will still succeed.' + operationId: unfollowTag + parameters: + - description: Name of the tag (no leading `#`) + in: path + name: tag_name + required: true + type: string + produces: + - application/json + 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 + security: + - OAuth2 Bearer: + - write:follows + summary: Unfollow a hashtag. + tags: + - tags /api/v1/timelines/home: get: description: |- 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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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"` } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 8187ba419..2949d528a 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -57,6 +57,7 @@ func (c *Caches) Init() { log.Infof(nil, "init: %p", c) c.initAccount() + c.initAccountIDsFollowingTag() c.initAccountNote() c.initAccountSettings() c.initAccountStats() @@ -98,6 +99,7 @@ func (c *Caches) Init() { c.initStatusFave() c.initStatusFaveIDs() c.initTag() + c.initTagIDsFollowedByAccount() c.initThreadMute() c.initToken() c.initTombstone() @@ -134,6 +136,7 @@ func (c *Caches) Stop() { // significant overhead to all cache writes. func (c *Caches) Sweep(threshold float64) { c.DB.Account.Trim(threshold) + c.DB.AccountIDsFollowingTag.Trim(threshold) c.DB.AccountNote.Trim(threshold) c.DB.AccountSettings.Trim(threshold) c.DB.AccountStats.Trim(threshold) @@ -142,6 +145,8 @@ func (c *Caches) Sweep(threshold float64) { c.DB.BlockIDs.Trim(threshold) c.DB.BoostOfIDs.Trim(threshold) c.DB.Client.Trim(threshold) + c.DB.Conversation.Trim(threshold) + c.DB.ConversationLastStatusIDs.Trim(threshold) c.DB.Emoji.Trim(threshold) c.DB.EmojiCategory.Trim(threshold) c.DB.Filter.Trim(threshold) @@ -171,6 +176,7 @@ func (c *Caches) Sweep(threshold float64) { c.DB.StatusFave.Trim(threshold) c.DB.StatusFaveIDs.Trim(threshold) c.DB.Tag.Trim(threshold) + c.DB.TagIDsFollowedByAccount.Trim(threshold) c.DB.ThreadMute.Trim(threshold) c.DB.Token.Trim(threshold) c.DB.Tombstone.Trim(threshold) diff --git a/internal/cache/db.go b/internal/cache/db.go index 16e1d286a..c1b87ef96 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -29,6 +29,9 @@ type DBCaches struct { // Account provides access to the gtsmodel Account database cache. Account StructCache[*gtsmodel.Account] + // AccountIDsFollowingTag caches account IDs following a given tag ID. + AccountIDsFollowingTag SliceCache[string] + // AccountNote provides access to the gtsmodel Note database cache. AccountNote StructCache[*gtsmodel.AccountNote] @@ -160,6 +163,9 @@ type DBCaches struct { // Tag provides access to the gtsmodel Tag database cache. Tag StructCache[*gtsmodel.Tag] + // TagIDsFollowedByAccount caches tag IDs followed by a given account ID. + TagIDsFollowedByAccount SliceCache[string] + // ThreadMute provides access to the gtsmodel ThreadMute database cache. ThreadMute StructCache[*gtsmodel.ThreadMute] @@ -234,6 +240,17 @@ func (c *Caches) initAccount() { }) } +func (c *Caches) initAccountIDsFollowingTag() { + // Calculate maximum cache size. + cap := calculateSliceCacheMax( + config.GetCacheAccountIDsFollowingTagMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + c.DB.AccountIDsFollowingTag.Init(0, cap) +} + func (c *Caches) initAccountNote() { // Calculate maximum cache size. cap := calculateResultCacheMax( @@ -1317,6 +1334,17 @@ func (c *Caches) initTag() { }) } +func (c *Caches) initTagIDsFollowedByAccount() { + // Calculate maximum cache size. + cap := calculateSliceCacheMax( + config.GetCacheTagIDsFollowedByAccountMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + c.DB.TagIDsFollowedByAccount.Init(0, cap) +} + func (c *Caches) initThreadMute() { cap := calculateResultCacheMax( sizeofThreadMute(), // model in-mem size. diff --git a/internal/config/config.go b/internal/config/config.go index 1b8cf2759..a6499d822 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -193,6 +193,7 @@ type HTTPClientConfiguration struct { type CacheConfiguration struct { MemoryTarget bytesize.Size `name:"memory-target"` AccountMemRatio float64 `name:"account-mem-ratio"` + AccountIDsFollowingTagMemRatio float64 `name:"account-ids-following-tag-mem-ratio"` AccountNoteMemRatio float64 `name:"account-note-mem-ratio"` AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"` AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"` @@ -232,6 +233,7 @@ type CacheConfiguration struct { StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"` StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"` TagMemRatio float64 `name:"tag-mem-ratio"` + TagIDsFollowedByAccountMemRatio float64 `name:"tag-ids-followed-by-account-mem-ratio"` ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"` TokenMemRatio float64 `name:"token-mem-ratio"` TombstoneMemRatio float64 `name:"tombstone-mem-ratio"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 82ea07e10..835841c84 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -157,6 +157,7 @@ var Defaults = Configuration{ // file have been addressed, these should // be able to make some more sense :D AccountMemRatio: 5, + AccountIDsFollowingTagMemRatio: 1, AccountNoteMemRatio: 1, AccountSettingsMemRatio: 0.1, AccountStatsMemRatio: 2, @@ -196,6 +197,7 @@ var Defaults = Configuration{ StatusFaveMemRatio: 2, StatusFaveIDsMemRatio: 3, TagMemRatio: 2, + TagIDsFollowedByAccountMemRatio: 1, ThreadMuteMemRatio: 0.2, TokenMemRatio: 0.75, TombstoneMemRatio: 0.5, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 932cb802d..587fba364 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -2775,6 +2775,37 @@ func GetCacheAccountMemRatio() float64 { return global.GetCacheAccountMemRatio() // SetCacheAccountMemRatio safely sets the value for global configuration 'Cache.AccountMemRatio' field func SetCacheAccountMemRatio(v float64) { global.SetCacheAccountMemRatio(v) } +// GetCacheAccountIDsFollowingTagMemRatio safely fetches the Configuration value for state's 'Cache.AccountIDsFollowingTagMemRatio' field +func (st *ConfigState) GetCacheAccountIDsFollowingTagMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.AccountIDsFollowingTagMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheAccountIDsFollowingTagMemRatio safely sets the Configuration value for state's 'Cache.AccountIDsFollowingTagMemRatio' field +func (st *ConfigState) SetCacheAccountIDsFollowingTagMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.AccountIDsFollowingTagMemRatio = v + st.reloadToViper() +} + +// CacheAccountIDsFollowingTagMemRatioFlag returns the flag name for the 'Cache.AccountIDsFollowingTagMemRatio' field +func CacheAccountIDsFollowingTagMemRatioFlag() string { + return "cache-account-ids-following-tag-mem-ratio" +} + +// GetCacheAccountIDsFollowingTagMemRatio safely fetches the value for global configuration 'Cache.AccountIDsFollowingTagMemRatio' field +func GetCacheAccountIDsFollowingTagMemRatio() float64 { + return global.GetCacheAccountIDsFollowingTagMemRatio() +} + +// SetCacheAccountIDsFollowingTagMemRatio safely sets the value for global configuration 'Cache.AccountIDsFollowingTagMemRatio' field +func SetCacheAccountIDsFollowingTagMemRatio(v float64) { + global.SetCacheAccountIDsFollowingTagMemRatio(v) +} + // GetCacheAccountNoteMemRatio safely fetches the Configuration value for state's 'Cache.AccountNoteMemRatio' field func (st *ConfigState) GetCacheAccountNoteMemRatio() (v float64) { st.mutex.RLock() @@ -3758,6 +3789,37 @@ func GetCacheTagMemRatio() float64 { return global.GetCacheTagMemRatio() } // SetCacheTagMemRatio safely sets the value for global configuration 'Cache.TagMemRatio' field func SetCacheTagMemRatio(v float64) { global.SetCacheTagMemRatio(v) } +// GetCacheTagIDsFollowedByAccountMemRatio safely fetches the Configuration value for state's 'Cache.TagIDsFollowedByAccountMemRatio' field +func (st *ConfigState) GetCacheTagIDsFollowedByAccountMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.TagIDsFollowedByAccountMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheTagIDsFollowedByAccountMemRatio safely sets the Configuration value for state's 'Cache.TagIDsFollowedByAccountMemRatio' field +func (st *ConfigState) SetCacheTagIDsFollowedByAccountMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.TagIDsFollowedByAccountMemRatio = v + st.reloadToViper() +} + +// CacheTagIDsFollowedByAccountMemRatioFlag returns the flag name for the 'Cache.TagIDsFollowedByAccountMemRatio' field +func CacheTagIDsFollowedByAccountMemRatioFlag() string { + return "cache-tag-ids-followed-by-account-mem-ratio" +} + +// GetCacheTagIDsFollowedByAccountMemRatio safely fetches the value for global configuration 'Cache.TagIDsFollowedByAccountMemRatio' field +func GetCacheTagIDsFollowedByAccountMemRatio() float64 { + return global.GetCacheTagIDsFollowedByAccountMemRatio() +} + +// SetCacheTagIDsFollowedByAccountMemRatio safely sets the value for global configuration 'Cache.TagIDsFollowedByAccountMemRatio' field +func SetCacheTagIDsFollowedByAccountMemRatio(v float64) { + global.SetCacheTagIDsFollowedByAccountMemRatio(v) +} + // GetCacheThreadMuteMemRatio safely fetches the Configuration value for state's 'Cache.ThreadMuteMemRatio' field func (st *ConfigState) GetCacheThreadMuteMemRatio() (v float64) { st.mutex.RLock() diff --git a/internal/db/bundb/migrations/20240725211933_add_followed_tags.go b/internal/db/bundb/migrations/20240725211933_add_followed_tags.go new file mode 100644 index 000000000..f86b7d070 --- /dev/null +++ b/internal/db/bundb/migrations/20240725211933_add_followed_tags.go @@ -0,0 +1,51 @@ +// 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 . + +package migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx. + NewCreateTable(). + Model(>smodel.FollowedTag{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/tag.go b/internal/db/bundb/tag.go index 5218a19d5..c6298ee64 100644 --- a/internal/db/bundb/tag.go +++ b/internal/db/bundb/tag.go @@ -19,9 +19,13 @@ package bundb import ( "context" + "errors" "strings" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/uptrace/bun" @@ -131,3 +135,158 @@ func (t *tagDB) PutTag(ctx context.Context, tag *gtsmodel.Tag) error { return nil } + +func (t *tagDB) GetFollowedTags(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Tag, error) { + tagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, page) + if err != nil { + return nil, err + } + + tags, err := t.GetTags(ctx, tagIDs) + if err != nil { + return nil, err + } + + return tags, nil +} + +func (t *tagDB) getTagIDsFollowedByAccount(ctx context.Context, accountID string, page *paging.Page) ([]string, error) { + return loadPagedIDs(&t.state.Caches.DB.TagIDsFollowedByAccount, accountID, page, func() ([]string, error) { + var tagIDs []string + + // Tag IDs not in cache. Perform DB query. + if _, err := t.db. + NewSelect(). + Model((*gtsmodel.FollowedTag)(nil)). + Column("tag_id"). + Where("? = ?", bun.Ident("account_id"), accountID). + OrderExpr("? DESC", bun.Ident("tag_id")). + Exec(ctx, &tagIDs); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("error getting tag IDs followed by account %s: %w", accountID, err) + } + + return tagIDs, nil + }) +} + +func (t *tagDB) getAccountIDsFollowingTag(ctx context.Context, tagID string) ([]string, error) { + return loadPagedIDs(&t.state.Caches.DB.AccountIDsFollowingTag, tagID, nil, func() ([]string, error) { + var accountIDs []string + + // Account IDs not in cache. Perform DB query. + if _, err := t.db. + NewSelect(). + Model((*gtsmodel.FollowedTag)(nil)). + Column("account_id"). + Where("? = ?", bun.Ident("tag_id"), tagID). + OrderExpr("? DESC", bun.Ident("account_id")). + Exec(ctx, &accountIDs); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("error getting account IDs following tag %s: %w", tagID, err) + } + + return accountIDs, nil + }) +} + +func (t *tagDB) IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error) { + accountTagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, nil) + if err != nil { + return false, err + } + + for _, accountTagID := range accountTagIDs { + if accountTagID == tagID { + return true, nil + } + } + + return false, nil +} + +func (t *tagDB) PutFollowedTag(ctx context.Context, accountID string, tagID string) error { + // Insert the followed tag. + result, err := t.db.NewInsert(). + Model(>smodel.FollowedTag{ + AccountID: accountID, + TagID: tagID, + }). + On("CONFLICT (?, ?) DO NOTHING", bun.Ident("account_id"), bun.Ident("tag_id")). + Exec(ctx) + if err != nil { + return gtserror.Newf("error inserting followed tag: %w", err) + } + + // If it fails because that account already follows that tag, that's fine, and we're done. + rows, err := result.RowsAffected() + if err != nil { + return gtserror.Newf("error getting inserted row count: %w", err) + } + if rows == 0 { + return nil + } + + // Otherwise, this is a new followed tag, so we invalidate caches related to it. + t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID) + t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID) + + return nil +} + +func (t *tagDB) DeleteFollowedTag(ctx context.Context, accountID string, tagID string) error { + result, err := t.db.NewDelete(). + Model((*gtsmodel.FollowedTag)(nil)). + Where("? = ?", bun.Ident("account_id"), accountID). + Where("? = ?", bun.Ident("tag_id"), tagID). + Exec(ctx) + if err != nil { + return gtserror.Newf("error deleting followed tag %s for account %s: %w", tagID, accountID, err) + } + + rows, err := result.RowsAffected() + if err != nil { + return gtserror.Newf("error getting inserted row count: %w", err) + } + if rows == 0 { + return nil + } + + // If we deleted anything, invalidate caches related to it. + t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID) + t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID) + + return err +} + +func (t *tagDB) DeleteFollowedTagsByAccountID(ctx context.Context, accountID string) error { + // Delete followed tags from the database, returning the list of tag IDs affected. + tagIDs := []string{} + if err := t.db.NewDelete(). + Model((*gtsmodel.FollowedTag)(nil)). + Where("? = ?", bun.Ident("account_id"), accountID). + Returning("?", bun.Ident("tag_id")). + Scan(ctx, &tagIDs); // nocollapse + err != nil { + return gtserror.Newf("error deleting followed tags for account %s: %w", accountID, err) + } + + // Invalidate account ID caches for the account and those tags. + t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID) + t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagIDs...) + + return nil +} + +func (t *tagDB) GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error) { + // Accounts might be following multiple tags in this list, but we only want to return each account once. + accountIDs := []string{} + for _, tagID := range tagIDs { + tagAccountIDs, err := t.getAccountIDsFollowingTag(ctx, tagID) + if err != nil { + return nil, err + } + accountIDs = append(accountIDs, tagAccountIDs...) + } + return util.UniqueStrings(accountIDs), nil +} diff --git a/internal/db/tag.go b/internal/db/tag.go index c0642f5a4..66c880e86 100644 --- a/internal/db/tag.go +++ b/internal/db/tag.go @@ -21,6 +21,7 @@ import ( "context" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // Tag contains functions for getting/creating tags in the database. @@ -36,4 +37,24 @@ type Tag interface { // GetTags gets multiple tags. GetTags(ctx context.Context, ids []string) ([]*gtsmodel.Tag, error) + + // GetFollowedTags gets the user's followed tags. + GetFollowedTags(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Tag, error) + + // IsAccountFollowingTag returns whether the account follows the given tag. + IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error) + + // PutFollowedTag creates a new followed tag for a the given user. + // If it already exists, it returns without an error. + PutFollowedTag(ctx context.Context, accountID string, tagID string) error + + // DeleteFollowedTag deletes a followed tag for a the given user. + // If no such followed tag exists, it returns without an error. + DeleteFollowedTag(ctx context.Context, accountID string, tagID string) error + + // DeleteFollowedTagsByAccountID deletes all of an account's followed tags. + DeleteFollowedTagsByAccountID(ctx context.Context, accountID string) error + + // GetAccountIDsFollowingTagIDs returns the account IDs of any followers of the given tag IDs. + GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error) } diff --git a/internal/gtsmodel/tag.go b/internal/gtsmodel/tag.go index 470bee094..0b66585eb 100644 --- a/internal/gtsmodel/tag.go +++ b/internal/gtsmodel/tag.go @@ -29,3 +29,12 @@ type Tag struct { Listable *bool `bun:",nullzero,notnull,default:true"` // Tagged statuses can be listed on this instance. Href string `bun:"-"` // Href of the hashtag. Will only be set on freshly-extracted hashtags from remote AP messages. Not stored in the database. } + +// FollowedTag represents a user following a tag. +type FollowedTag struct { + // ID of the account that follows the tag. + AccountID string `bun:"type:CHAR(26),pk,nullzero"` + + // ID of the tag. + TagID string `bun:"type:CHAR(26),pk,nullzero"` +} diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index 702b46cda..c8d1ba5f9 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -474,6 +474,12 @@ func (p *Processor) deleteAccountPeripheral(ctx context.Context, account *gtsmod return gtserror.Newf("error deleting poll votes by account: %w", err) } + // Delete all followed tags owned by given account. + if err := p.state.DB.DeleteFollowedTagsByAccountID(ctx, account.ID); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("error deleting followed tags by account: %w", err) + } + // Delete account stats model. if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil { return gtserror.Newf("error deleting stats for account: %w", err) diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 0afe8356b..6d39dc103 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -42,6 +42,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/search" "github.com/superseriousbusiness/gotosocial/internal/processing/status" "github.com/superseriousbusiness/gotosocial/internal/processing/stream" + "github.com/superseriousbusiness/gotosocial/internal/processing/tags" "github.com/superseriousbusiness/gotosocial/internal/processing/timeline" "github.com/superseriousbusiness/gotosocial/internal/processing/user" "github.com/superseriousbusiness/gotosocial/internal/processing/workers" @@ -88,6 +89,7 @@ type Processor struct { search search.Processor status status.Processor stream stream.Processor + tags tags.Processor timeline timeline.Processor user user.Processor workers workers.Processor @@ -153,6 +155,10 @@ func (p *Processor) Stream() *stream.Processor { return &p.stream } +func (p *Processor) Tags() *tags.Processor { + return &p.tags +} + func (p *Processor) Timeline() *timeline.Processor { return &p.timeline } @@ -207,6 +213,7 @@ func NewProcessor( processor.markers = markers.New(state, converter) processor.polls = polls.New(&common, state, converter) processor.report = report.New(state, converter) + processor.tags = tags.New(state, converter) processor.timeline = timeline.New(state, converter, visFilter) processor.search = search.New(state, federator, converter, visFilter) processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc) diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go index 8043affd9..e2947ea16 100644 --- a/internal/processing/search/util.go +++ b/internal/processing/search/util.go @@ -146,7 +146,7 @@ func (p *Processor) packageHashtags( } else { // If API not version 1, provide slice of full tags. rangeF = func(tag *gtsmodel.Tag) { - apiTag, err := p.converter.TagToAPITag(ctx, tag, true) + apiTag, err := p.converter.TagToAPITag(ctx, tag, true, nil) if err != nil { log.Debugf( ctx, diff --git a/internal/processing/tags/follow.go b/internal/processing/tags/follow.go new file mode 100644 index 000000000..f840f4bb7 --- /dev/null +++ b/internal/processing/tags/follow.go @@ -0,0 +1,67 @@ +// 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 . + +package tags + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +// Follow follows the tag with the given name as the given account. +// If there is no tag with that name, it creates a tag. +func (p *Processor) Follow( + ctx context.Context, + account *gtsmodel.Account, + name string, +) (*apimodel.Tag, gtserror.WithCode) { + // Try to get an existing tag with that name. + tag, err := p.state.DB.GetTagByName(ctx, name) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("DB error getting tag with name %s: %w", name, err), + ) + } + + // If there is no such tag, create it. + if tag == nil { + tag = >smodel.Tag{ + ID: id.NewULID(), + Name: name, + } + if err := p.state.DB.PutTag(ctx, tag); err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("DB error creating tag with name %s: %w", name, err), + ) + } + } + + // Follow the tag. + if err := p.state.DB.PutFollowedTag(ctx, account.ID, tag.ID); err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("DB error following tag %s: %w", tag.ID, err), + ) + } + + return p.apiTag(ctx, tag, true) +} diff --git a/internal/processing/tags/followed.go b/internal/processing/tags/followed.go new file mode 100644 index 000000000..b9c450653 --- /dev/null +++ b/internal/processing/tags/followed.go @@ -0,0 +1,73 @@ +// 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 . + +package tags + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Followed gets the user's list of followed tags. +func (p *Processor) Followed( + ctx context.Context, + accountID string, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + tags, err := p.state.DB.GetFollowedTags(ctx, + accountID, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("DB error getting followed tags for account %s: %w", accountID, err), + ) + } + + count := len(tags) + if len(tags) == 0 { + return util.EmptyPageableResponse(), nil + } + + lo := tags[count-1].ID + hi := tags[0].ID + + items := make([]interface{}, 0, count) + following := util.Ptr(true) + for _, tag := range tags { + apiTag, err := p.converter.TagToAPITag(ctx, tag, true, following) + if err != nil { + log.Errorf(ctx, "error converting tag %s to API representation: %v", tag.ID, err) + continue + } + items = append(items, apiTag) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/followed_tags", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} diff --git a/internal/processing/tags/followedtags.go b/internal/processing/tags/followedtags.go new file mode 100644 index 000000000..c9093a6c6 --- /dev/null +++ b/internal/processing/tags/followedtags.go @@ -0,0 +1,53 @@ +// 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 . + +package tags + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +type Processor struct { + state *state.State + converter *typeutils.Converter +} + +func New(state *state.State, converter *typeutils.Converter) Processor { + return Processor{ + state: state, + converter: converter, + } +} + +// apiTag is a shortcut to return the API version of the given tag, +// or return an appropriate error if conversion fails. +func (p *Processor) apiTag(ctx context.Context, tag *gtsmodel.Tag, following bool) (*apimodel.Tag, gtserror.WithCode) { + apiTag, err := p.converter.TagToAPITag(ctx, tag, true, &following) + if err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("error converting tag %s to API representation: %w", tag.Name, err), + ) + } + + return &apiTag, nil +} diff --git a/internal/processing/tags/get.go b/internal/processing/tags/get.go new file mode 100644 index 000000000..c8fa66137 --- /dev/null +++ b/internal/processing/tags/get.go @@ -0,0 +1,57 @@ +// 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 . + +package tags + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Get gets the tag with the given name, including whether it's followed by the given account. +func (p *Processor) Get( + ctx context.Context, + account *gtsmodel.Account, + name string, +) (*apimodel.Tag, gtserror.WithCode) { + // Try to get an existing tag with that name. + tag, err := p.state.DB.GetTagByName(ctx, name) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("DB error getting tag with name %s: %w", name, err), + ) + } + if tag == nil { + return nil, gtserror.NewErrorNotFound( + gtserror.Newf("couldn't find tag with name %s: %w", name, err), + ) + } + + following, err := p.state.DB.IsAccountFollowingTag(ctx, account.ID, tag.ID) + if err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("DB error checking whether account %s follows tag %s: %w", account.ID, tag.ID, err), + ) + } + + return p.apiTag(ctx, tag, following) +} diff --git a/internal/processing/tags/unfollow.go b/internal/processing/tags/unfollow.go new file mode 100644 index 000000000..fb844cd9f --- /dev/null +++ b/internal/processing/tags/unfollow.go @@ -0,0 +1,58 @@ +// 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 . + +package tags + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Unfollow unfollows the tag with the given name as the given account. +// If there is no tag with that name, it creates a tag. +func (p *Processor) Unfollow( + ctx context.Context, + account *gtsmodel.Account, + name string, +) (*apimodel.Tag, gtserror.WithCode) { + // Try to get an existing tag with that name. + tag, err := p.state.DB.GetTagByName(ctx, name) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("DB error getting tag with name %s: %w", name, err), + ) + } + if tag == nil { + return nil, gtserror.NewErrorNotFound( + gtserror.Newf("couldn't find tag with name %s: %w", name, err), + ) + } + + // Unfollow the tag. + if err := p.state.DB.DeleteFollowedTag(ctx, account.ID, tag.ID); err != nil { + return nil, gtserror.NewErrorInternalError( + gtserror.Newf("DB error unfollowing tag %s: %w", tag.ID, err), + ) + } + + return p.apiTag(ctx, tag, false) +} diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index 35c2c31b7..b4eae0be0 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -52,6 +52,7 @@ func (suite *FromClientAPITestSuite) newStatus( boostOfStatus *gtsmodel.Status, mentionedAccounts []*gtsmodel.Account, createThread bool, + tagIDs []string, ) *gtsmodel.Status { var ( protocol = config.GetProtocol() @@ -65,6 +66,7 @@ func (suite *FromClientAPITestSuite) newStatus( URI: protocol + "://" + host + "/users/" + account.Username + "/statuses/" + statusID, URL: protocol + "://" + host + "/@" + account.Username + "/statuses/" + statusID, Content: "pee pee poo poo", + TagIDs: tagIDs, Local: util.Ptr(true), AccountURI: account.URI, AccountID: account.ID, @@ -256,6 +258,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { nil, nil, false, + nil, ) ) @@ -367,6 +370,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { nil, nil, false, + nil, ) ) @@ -428,6 +432,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() { nil, nil, false, + nil, ) threadMute = >smodel.ThreadMute{ ID: "01HD3KRMBB1M85QRWHD912QWRE", @@ -488,6 +493,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() { suite.testStatuses["local_account_1_status_1"], nil, false, + nil, ) threadMute = >smodel.ThreadMute{ ID: "01HD3KRMBB1M85QRWHD912QWRE", @@ -553,6 +559,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis nil, nil, false, + nil, ) ) @@ -628,6 +635,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis nil, nil, false, + nil, ) ) @@ -708,6 +716,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli nil, nil, false, + nil, ) ) @@ -780,6 +789,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() { suite.testStatuses["local_account_2_status_1"], nil, false, + nil, ) ) @@ -843,6 +853,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() { suite.testStatuses["local_account_2_status_1"], nil, false, + nil, ) ) @@ -912,6 +923,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversat nil, []*gtsmodel.Account{receivingAccount}, true, + nil, ) ) @@ -997,6 +1009,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate nil, []*gtsmodel.Account{receivingAccount}, true, + nil, ) ) @@ -1038,6 +1051,555 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate ) } +// A public status with a hashtag followed by a local user who does not otherwise follow the author +// should end up in the tag-following user's home timeline. +func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtag() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block postingAccount or vice versa. + blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the new status. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + true, + "", + stream.EventTypeUpdate, + ) +} + +// A public status with a hashtag followed by a local user who does not otherwise follow the author +// should not end up in the tag-following user's home timeline +// if the user has the author blocked. +func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtagAndBlock() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["remote_account_1"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: postingAccount does not block receivingAccount. + blocking, err := testStructs.State.DB.IsBlocked(ctx, postingAccount.ID, receivingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount blocks postingAccount. + blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the new status. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + false, + "", + "", + ) +} + +// A boost of a public status with a hashtag followed by a local user +// who does not otherwise follow the author or booster +// should end up in the tag-following user's home timeline as the original status. +func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtag() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["remote_account_2"] + boostingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + + // boostingAccount boosts that status. + boost = suite.newStatus( + ctx, + testStructs.State, + boostingAccount, + gtsmodel.VisibilityPublic, + nil, + status, + nil, + false, + nil, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block postingAccount or vice versa. + blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount does not follow boostingAccount. + following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block boostingAccount or vice versa. + blocking, err = testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the boost. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityCreate, + GTSModel: boost, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + true, + "", + stream.EventTypeUpdate, + ) +} + +// A boost of a public status with a hashtag followed by a local user +// who does not otherwise follow the author or booster +// should not end up in the tag-following user's home timeline +// if the user has the author blocked. +func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAndBlock() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["remote_account_1"] + boostingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + + // boostingAccount boosts that status. + boost = suite.newStatus( + ctx, + testStructs.State, + boostingAccount, + gtsmodel.VisibilityPublic, + nil, + status, + nil, + false, + nil, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: postingAccount does not block receivingAccount. + blocking, err := testStructs.State.DB.IsBlocked(ctx, postingAccount.ID, receivingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount blocks postingAccount. + blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(blocking) + + // Check precondition: receivingAccount does not follow boostingAccount. + following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block boostingAccount or vice versa. + blocking, err = testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the boost. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityCreate, + GTSModel: boost, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + false, + "", + "", + ) +} + +// A boost of a public status with a hashtag followed by a local user +// who does not otherwise follow the author or booster +// should not end up in the tag-following user's home timeline +// if the user has the booster blocked. +func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAndBlockedBoost() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + boostingAccount = suite.testAccounts["remote_account_1"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + + // boostingAccount boosts that status. + boost = suite.newStatus( + ctx, + testStructs.State, + boostingAccount, + gtsmodel.VisibilityPublic, + nil, + status, + nil, + false, + nil, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block postingAccount or vice versa. + blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount does not follow boostingAccount. + following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: boostingAccount does not block receivingAccount. + blocking, err = testStructs.State.DB.IsBlocked(ctx, boostingAccount.ID, receivingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount blocks boostingAccount. + blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the boost. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityCreate, + GTSModel: boost, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + false, + "", + "", + ) +} + +// Updating a public status with a hashtag followed by a local user who does not otherwise follow the author +// should stream a status update to the tag-following user's home timeline. +func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block postingAccount or vice versa. + blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Update the status. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityUpdate, + GTSModel: status, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + true, + "", + stream.EventTypeStatusUpdate, + ) +} + func (suite *FromClientAPITestSuite) TestProcessStatusDelete() { testStructs := suite.SetupTestStructs() defer suite.TearDownTestStructs(testStructs) diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go index 7bd0a51c6..c0987effd 100644 --- a/internal/processing/workers/surfacetimeline.go +++ b/internal/processing/workers/surfacetimeline.go @@ -30,10 +30,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/stream" "github.com/superseriousbusiness/gotosocial/internal/timeline" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // timelineAndNotifyStatus inserts the given status into the HOME -// and LIST timelines of accounts that follow the status author. +// and LIST timelines of accounts that follow the status author, +// as well as the HOME timelines of accounts that follow tags used by the status. // // It will also handle notifications for any mentions attached to // the account, notifications for any local accounts that want @@ -56,18 +58,24 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel. follows = append(follows, >smodel.Follow{ AccountID: status.AccountID, Account: status.Account, - Notify: func() *bool { b := false; return &b }(), // Account shouldn't notify itself. - ShowReblogs: func() *bool { b := true; return &b }(), // Account should show own reblogs. + Notify: util.Ptr(false), // Account shouldn't notify itself. + ShowReblogs: util.Ptr(true), // Account should show own reblogs. }) } // Timeline the status for each local follower of this account. // This will also handle notifying any followers with notify // set to true on their follow. - if err := s.timelineAndNotifyStatusForFollowers(ctx, status, follows); err != nil { + homeTimelinedAccountIDs, err := s.timelineAndNotifyStatusForFollowers(ctx, status, follows) + if err != nil { return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err) } + // Timeline the status for each local account who follows a tag used by this status. + if err := s.timelineAndNotifyStatusForTagFollowers(ctx, status, homeTimelinedAccountIDs); err != nil { + return gtserror.Newf("error timelining status %s for tag followers: %w", status.ID, err) + } + // Notify each local account that's mentioned by this status. if err := s.notifyMentions(ctx, status); err != nil { return gtserror.Newf("error notifying status mentions for status %s: %w", status.ID, err) @@ -90,15 +98,18 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel. // adding the status to list timelines + home timelines of each // follower, as appropriate, and notifying each follower of the // new status, if the status is eligible for notification. +// +// Returns a list of accounts which had this status inserted into their home timelines. func (s *Surface) timelineAndNotifyStatusForFollowers( ctx context.Context, status *gtsmodel.Status, follows []*gtsmodel.Follow, -) error { +) ([]string, error) { var ( - errs gtserror.MultiError - boost = status.BoostOfID != "" - reply = status.InReplyToURI != "" + errs gtserror.MultiError + boost = status.BoostOfID != "" + reply = status.InReplyToURI != "" + homeTimelinedAccountIDs = []string{} ) for _, follow := range follows { @@ -122,17 +133,12 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( continue } - filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID) + filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID) if err != nil { - return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err) + errs.Append(err) + continue } - mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil) - if err != nil { - return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - // Add status to any relevant lists // for this follow, if applicable. s.listTimelineStatusForFollow( @@ -141,7 +147,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( follow, &errs, filters, - compiledMutes, + mutes, ) // Add status to home timeline for owner @@ -154,7 +160,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( status, stream.TimelineHome, filters, - compiledMutes, + mutes, ) if err != nil { errs.Appendf("error home timelining status: %w", err) @@ -166,6 +172,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // timeline, we shouldn't notify it. continue } + homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) if !*follow.Notify { // This follower doesn't have notifs @@ -196,7 +203,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( } } - return errs.Combine() + return homeTimelinedAccountIDs, errs.Combine() } // listTimelineStatusForFollow puts the given status @@ -259,6 +266,22 @@ func (s *Surface) listTimelineStatusForFollow( } } +// getFiltersAndMutes returns an account's filters and mutes. +func (s *Surface) getFiltersAndMutes(ctx context.Context, accountID string) ([]*gtsmodel.Filter, *usermute.CompiledUserMuteList, error) { + filters, err := s.State.DB.GetFiltersForAccountID(ctx, accountID) + if err != nil { + return nil, nil, gtserror.Newf("couldn't retrieve filters for account %s: %w", accountID, err) + } + + mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), accountID, nil) + if err != nil { + return nil, nil, gtserror.Newf("couldn't retrieve mutes for account %s: %w", accountID, err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + + return filters, compiledMutes, err +} + // listEligible checks if the given status is eligible // for inclusion in the list that that the given listEntry // belongs to, based on the replies policy of the list. @@ -391,6 +414,138 @@ func (s *Surface) timelineStatus( return true, nil } +// timelineAndNotifyStatusForTagFollowers inserts the status into the +// home timeline of each local account which follows a useable tag from the status, +// skipping accounts for which it would have already been inserted. +func (s *Surface) timelineAndNotifyStatusForTagFollowers( + ctx context.Context, + status *gtsmodel.Status, + alreadyHomeTimelinedAccountIDs []string, +) error { + tagFollowerAccounts, err := s.tagFollowersForStatus(ctx, status, alreadyHomeTimelinedAccountIDs) + if err != nil { + return err + } + + if status.BoostOf != nil { + // Unwrap boost and work with the original status. + status = status.BoostOf + } + + // Insert the status into the home timeline of each tag follower. + errs := gtserror.MultiError{} + for _, tagFollowerAccount := range tagFollowerAccounts { + filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID) + if err != nil { + errs.Append(err) + continue + } + + if _, err := s.timelineStatus( + ctx, + s.State.Timelines.Home.IngestOne, + tagFollowerAccount.ID, // home timelines are keyed by account ID + tagFollowerAccount, + status, + stream.TimelineHome, + filters, + mutes, + ); err != nil { + errs.Appendf( + "error inserting status %s into home timeline for account %s: %w", + status.ID, + tagFollowerAccount.ID, + err, + ) + } + } + return errs.Combine() +} + +// tagFollowersForStatus gets local accounts which follow any useable tags from the status, +// skipping any with IDs in the provided list, and any that shouldn't be able to see it due to blocks. +func (s *Surface) tagFollowersForStatus( + ctx context.Context, + status *gtsmodel.Status, + skipAccountIDs []string, +) ([]*gtsmodel.Account, error) { + // If the status is a boost, look at the tags from the boosted status. + taggedStatus := status + if status.BoostOf != nil { + taggedStatus = status.BoostOf + } + + if taggedStatus.Visibility != gtsmodel.VisibilityPublic || len(taggedStatus.Tags) == 0 { + // Only public statuses with tags are eligible for tag processing. + return nil, nil + } + + // Build list of useable tag IDs. + useableTagIDs := make([]string, 0, len(taggedStatus.Tags)) + for _, tag := range taggedStatus.Tags { + if *tag.Useable { + useableTagIDs = append(useableTagIDs, tag.ID) + } + } + if len(useableTagIDs) == 0 { + return nil, nil + } + + // Get IDs for all accounts who follow one or more of the useable tags from this status. + allTagFollowerAccountIDs, err := s.State.DB.GetAccountIDsFollowingTagIDs(ctx, useableTagIDs) + if err != nil { + return nil, gtserror.Newf("DB error getting followers for tags of status %s: %w", taggedStatus.ID, err) + } + if len(allTagFollowerAccountIDs) == 0 { + return nil, nil + } + + // Build set for faster lookup of account IDs to skip. + skipAccountIDSet := make(map[string]struct{}, len(skipAccountIDs)) + for _, accountID := range skipAccountIDs { + skipAccountIDSet[accountID] = struct{}{} + } + + // Build list of tag follower account IDs, + // except those which have already had this status inserted into their timeline. + tagFollowerAccountIDs := make([]string, 0, len(allTagFollowerAccountIDs)) + for _, accountID := range allTagFollowerAccountIDs { + if _, skip := skipAccountIDSet[accountID]; skip { + continue + } + tagFollowerAccountIDs = append(tagFollowerAccountIDs, accountID) + } + if len(tagFollowerAccountIDs) == 0 { + return nil, nil + } + + // Retrieve accounts for remaining tag followers. + tagFollowerAccounts, err := s.State.DB.GetAccountsByIDs(ctx, tagFollowerAccountIDs) + if err != nil { + return nil, gtserror.Newf("DB error getting accounts for followers of tags of status %s: %w", taggedStatus.ID, err) + } + + // Check the visibility of the *input* status for each account. + // This accounts for the visibility of the boost as well as the original, if the input status is a boost. + errs := gtserror.MultiError{} + visibleTagFollowerAccounts := make([]*gtsmodel.Account, 0, len(tagFollowerAccounts)) + for _, account := range tagFollowerAccounts { + visible, err := s.VisFilter.StatusVisible(ctx, account, status) + if err != nil { + errs.Appendf( + "error checking visibility of status %s to account %s", + status.ID, + account.ID, + ) + } + if visible { + visibleTagFollowerAccounts = append(visibleTagFollowerAccounts, account) + } + } + + return visibleTagFollowerAccounts, errs.Combine() +} + // deleteStatusFromTimelines completely removes the given status from all timelines. // It will also stream deletion of the status to all open streams. func (s *Surface) deleteStatusFromTimelines(ctx context.Context, statusID string) error { @@ -425,7 +580,7 @@ func (s *Surface) invalidateStatusFromTimelines(ctx context.Context, statusID st } // timelineStatusUpdate looks up HOME and LIST timelines of accounts -// that follow the the status author and pushes edit messages into any +// that follow the the status author or tags and pushes edit messages into any // active streams. // Note that calling invalidateStatusFromTimelines takes care of the // state in general, we just need to do this for any streams that are @@ -454,10 +609,15 @@ func (s *Surface) timelineStatusUpdate(ctx context.Context, status *gtsmodel.Sta } // Push to streams for each local follower of this account. - if err := s.timelineStatusUpdateForFollowers(ctx, status, follows); err != nil { + homeTimelinedAccountIDs, err := s.timelineStatusUpdateForFollowers(ctx, status, follows) + if err != nil { return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err) } + if err := s.timelineStatusUpdateForTagFollowers(ctx, status, homeTimelinedAccountIDs); err != nil { + return gtserror.Newf("error timelining status %s for tag followers: %w", status.ID, err) + } + return nil } @@ -465,13 +625,16 @@ func (s *Surface) timelineStatusUpdate(ctx context.Context, status *gtsmodel.Sta // slice of followers of the account that posted the given status, // pushing update messages into open list/home streams of each // follower. +// +// Returns a list of accounts which had this status updated in their home timelines. func (s *Surface) timelineStatusUpdateForFollowers( ctx context.Context, status *gtsmodel.Status, follows []*gtsmodel.Follow, -) error { +) ([]string, error) { var ( - errs gtserror.MultiError + errs gtserror.MultiError + homeTimelinedAccountIDs = []string{} ) for _, follow := range follows { @@ -495,17 +658,12 @@ func (s *Surface) timelineStatusUpdateForFollowers( continue } - filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID) + filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID) if err != nil { - return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err) + errs.Append(err) + continue } - mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil) - if err != nil { - return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - // Add status to any relevant lists // for this follow, if applicable. s.listTimelineStatusUpdateForFollow( @@ -514,26 +672,30 @@ func (s *Surface) timelineStatusUpdateForFollowers( follow, &errs, filters, - compiledMutes, + mutes, ) // Add status to home timeline for owner // of this follow, if applicable. - err = s.timelineStreamStatusUpdate( + homeTimelined, err := s.timelineStreamStatusUpdate( ctx, follow.Account, status, stream.TimelineHome, filters, - compiledMutes, + mutes, ) if err != nil { errs.Appendf("error home timelining status: %w", err) continue } + + if homeTimelined { + homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) + } } - return errs.Combine() + return homeTimelinedAccountIDs, errs.Combine() } // listTimelineStatusUpdateForFollow pushes edits of the given status @@ -580,7 +742,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow( // At this point we are certain this status // should be included in the timeline of the // list that this list entry belongs to. - if err := s.timelineStreamStatusUpdate( + if _, err := s.timelineStreamStatusUpdate( ctx, follow.Account, status, @@ -596,6 +758,8 @@ func (s *Surface) listTimelineStatusUpdateForFollow( // timelineStatusUpdate streams the edited status to the user using the // given streamType. +// +// Returns whether it was actually streamed. func (s *Surface) timelineStreamStatusUpdate( ctx context.Context, account *gtsmodel.Account, @@ -603,16 +767,62 @@ func (s *Surface) timelineStreamStatusUpdate( streamType string, filters []*gtsmodel.Filter, mutes *usermute.CompiledUserMuteList, -) error { +) (bool, error) { apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters, mutes) if errors.Is(err, statusfilter.ErrHideStatus) { // Don't put this status in the stream. - return nil + return false, nil } if err != nil { err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) - return err + return false, err } s.Stream.StatusUpdate(ctx, account, apiStatus, streamType) - return nil + return true, nil +} + +// timelineStatusUpdateForTagFollowers streams update notifications to the +// home timeline of each local account which follows a tag used by the status, +// skipping accounts for which it would have already been streamed. +func (s *Surface) timelineStatusUpdateForTagFollowers( + ctx context.Context, + status *gtsmodel.Status, + alreadyHomeTimelinedAccountIDs []string, +) error { + tagFollowerAccounts, err := s.tagFollowersForStatus(ctx, status, alreadyHomeTimelinedAccountIDs) + if err != nil { + return err + } + + if status.BoostOf != nil { + // Unwrap boost and work with the original status. + status = status.BoostOf + } + + // Stream the update to the home timeline of each tag follower. + errs := gtserror.MultiError{} + for _, tagFollowerAccount := range tagFollowerAccounts { + filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID) + if err != nil { + errs.Append(err) + continue + } + + if _, err := s.timelineStreamStatusUpdate( + ctx, + tagFollowerAccount, + status, + stream.TimelineHome, + filters, + mutes, + ); err != nil { + errs.Appendf( + "error updating status %s on home timeline for account %s: %w", + status.ID, + tagFollowerAccount.ID, + err, + ) + } + } + return errs.Combine() } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index cbe746d2f..c555e1d04 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -740,7 +740,8 @@ func (c *Converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, categor // TagToAPITag converts a gts model tag into its api (frontend) representation for serialization on the API. // If stubHistory is set to 'true', then the 'history' field of the tag will be populated with a pointer to an empty slice, for API compatibility reasons. -func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistory bool) (apimodel.Tag, error) { +// following is an optional flag marking whether the currently authenticated user (if there is one) is following the tag. +func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistory bool, following *bool) (apimodel.Tag, error) { return apimodel.Tag{ Name: strings.ToLower(t.Name), URL: uris.URIForTag(t.Name), @@ -752,6 +753,7 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor h := make([]any, 0) return &h }(), + Following: following, }, nil } @@ -2347,7 +2349,7 @@ func (c *Converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.T // Convert GTS models to frontend models for _, tag := range tags { - apiTag, err := c.TagToAPITag(ctx, tag, false) + apiTag, err := c.TagToAPITag(ctx, tag, false, nil) if err != nil { errs.Appendf("error converting tag %s to api tag: %w", tag.ID, err) continue diff --git a/test/envparsing.sh b/test/envparsing.sh index 83dfb85fc..281bf7405 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -23,6 +23,7 @@ EXPECT=$(cat << "EOF" "application-name": "gts", "bind-address": "127.0.0.1", "cache": { + "account-ids-following-tag-mem-ratio": 1, "account-mem-ratio": 5, "account-note-mem-ratio": 1, "account-settings-mem-ratio": 0.1, @@ -63,6 +64,7 @@ EXPECT=$(cat << "EOF" "status-fave-ids-mem-ratio": 3, "status-fave-mem-ratio": 2, "status-mem-ratio": 5, + "tag-ids-followed-by-account-mem-ratio": 1, "tag-mem-ratio": 2, "thread-mute-mem-ratio": 0.2, "token-mem-ratio": 0.75,