[feature] Implement following hashtags (#3141)

* Implement followed tags API

* Insert statuses with followed tags into home timelines

* Test following and unfollowing tags

* Correct Swagger path params

* Trim conversation caches

* Migration for followed_tags table

* Followed tag caches and DB implementation

* Lint and tests

* Add missing tag info endpoint, reorganize tag API

* Unwrap boosts when timelining based on tags

* Apply visibility filters to tag followers

* Address review comments
This commit is contained in:
Vyr Cossont 2024-07-29 11:26:31 -07:00 committed by GitHub
parent 368c97f0f8
commit a237e2b295
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2820 additions and 46 deletions

View file

@ -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: |-

View file

@ -32,6 +32,7 @@
"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 @@
"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),
}

View file

@ -34,7 +34,7 @@
//
// ---
// tags:
// - featured_tags
// - tags
//
// produces:
// - application/json

View file

@ -0,0 +1,43 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package followedtags
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/processing"
)
const (
BasePath = "/v1/followed_tags"
)
type Module struct {
processor *processing.Processor
}
func New(processor *processing.Processor) *Module {
return &Module{
processor: processor,
}
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodGet, BasePath, m.FollowedTagsGETHandler)
}

View file

@ -0,0 +1,104 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package followedtags_test
import (
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type FollowedTagsTestSuite struct {
suite.Suite
db db.DB
storage *storage.Driver
mediaManager *media.Manager
federator *federation.Federator
processor *processing.Processor
emailSender email.Sender
sentEmails map[string]string
state state.State
// standard suite models
testTokens map[string]*gtsmodel.Token
testClients map[string]*gtsmodel.Client
testApplications map[string]*gtsmodel.Application
testUsers map[string]*gtsmodel.User
testAccounts map[string]*gtsmodel.Account
testTags map[string]*gtsmodel.Tag
// module being tested
followedTagsModule *followedtags.Module
}
func (suite *FollowedTagsTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
suite.testTags = testrig.NewTestTags()
}
func (suite *FollowedTagsTestSuite) SetupTest() {
suite.state.Caches.Init()
testrig.StartNoopWorkers(&suite.state)
testrig.InitTestConfig()
config.Config(func(cfg *config.Configuration) {
cfg.WebAssetBaseDir = "../../../../web/assets/"
cfg.WebTemplateBaseDir = "../../../../web/templates/"
})
testrig.InitTestLog()
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.storage = testrig.NewInMemoryStorage()
suite.state.Storage = suite.storage
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
suite.followedTagsModule = followedtags.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
}
func (suite *FollowedTagsTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
testrig.StandardStorageTeardown(suite.storage)
testrig.StopWorkers(&suite.state)
}
func TestFollowedTagsTestSuite(t *testing.T) {
suite.Run(t, new(FollowedTagsTestSuite))
}

View file

@ -0,0 +1,139 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package followedtags
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// FollowedTagsGETHandler swagger:operation GET /api/v1/followed_tags getFollowedTags
//
// Get an array of all hashtags that you currently follow.
//
// ---
// tags:
// - tags
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - read:follows
//
// parameters:
// -
// name: max_id
// type: string
// description: >-
// Return only followed tags *OLDER* than the given max ID.
// The followed tag with the specified ID will not be included in the response.
// NOTE: the ID is of the internal followed tag, NOT a tag name.
// in: query
// required: false
// -
// name: since_id
// type: string
// description: >-
// Return only followed tags *NEWER* than the given since ID.
// The followed tag with the specified ID will not be included in the response.
// NOTE: the ID is of the internal followed tag, NOT a tag name.
// in: query
// -
// name: min_id
// type: string
// description: >-
// Return only followed tags *IMMEDIATELY NEWER* than the given min ID.
// The followed tag with the specified ID will not be included in the response.
// NOTE: the ID is of the internal followed tag, NOT a tag name.
// in: query
// required: false
// -
// name: limit
// type: integer
// description: Number of followed tags to return.
// default: 100
// minimum: 1
// maximum: 200
// in: query
// required: false
//
// responses:
// '200':
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// schema:
// type: array
// items:
// "$ref": "#/definitions/tag"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) FollowedTagsGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
page, errWithCode := paging.ParseIDPage(c,
1, // min limit
200, // max limit
100, // default limit
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Tags().Followed(
c.Request.Context(),
authed.Account.ID,
page,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
apiutil.JSON(c, http.StatusOK, resp.Items)
}

View file

@ -0,0 +1,125 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package followedtags_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FollowedTagsTestSuite) getFollowedTags(
accountFixtureName string,
expectedHTTPStatus int,
expectedBody string,
) ([]apimodel.Tag, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[accountFixtureName]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
// create the request
ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+followedtags.BasePath, nil)
ctx.Request.Header.Set("accept", "application/json")
// trigger the handler
suite.followedTagsModule.FollowedTagsGETHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
return nil, err
}
errs := gtserror.NewMultiError(2)
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs.Appendf("expected %s got %s", expectedBody, string(b))
}
return nil, errs.Combine()
}
resp := []apimodel.Tag{}
if err := json.Unmarshal(b, &resp); err != nil {
return nil, err
}
return resp, nil
}
// Test that we can list a user's followed tags.
func (suite *FollowedTagsTestSuite) TestGet() {
accountFixtureName := "local_account_2"
testAccount := suite.testAccounts[accountFixtureName]
testTag := suite.testTags["welcome"]
// Follow an existing tag.
if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
suite.FailNow(err.Error())
}
followedTags, err := suite.getFollowedTags(accountFixtureName, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
if suite.Len(followedTags, 1) {
followedTag := followedTags[0]
suite.Equal(testTag.Name, followedTag.Name)
if suite.NotNil(followedTag.Following) {
suite.True(*followedTag.Following)
}
}
}
// Test that we can list a user's followed tags even if they don't have any.
func (suite *FollowedTagsTestSuite) TestGetEmpty() {
accountFixtureName := "local_account_1"
followedTags, err := suite.getFollowedTags(accountFixtureName, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(followedTags, 0)
}

View file

@ -0,0 +1,92 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package tags
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// FollowTagPOSTHandler swagger:operation POST /api/v1/tags/{tag_name}/follow followTag
//
// Follow a hashtag.
//
// Idempotent: if you are already following the tag, this call will still succeed.
//
// ---
// tags:
// - tags
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - write:follows
//
// parameters:
// -
// name: tag_name
// type: string
// description: Name of the tag (no leading `#`)
// in: path
// required: true
//
// responses:
// '200':
// description: "Info about the tag."
// schema:
// "$ref": "#/definitions/tag"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '500':
// description: internal server error
func (m *Module) FollowTagPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiTag, errWithCode := m.processor.Tags().Follow(c.Request.Context(), authed.Account, name)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiTag)
}

View file

@ -0,0 +1,82 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package tags_test
import (
"context"
"net/http"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
)
func (suite *TagsTestSuite) follow(
accountFixtureName string,
tagName string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.Tag, error) {
return suite.tagAction(
accountFixtureName,
tagName,
http.MethodPost,
tags.FollowPath,
suite.tagsModule.FollowTagPOSTHandler,
expectedHTTPStatus,
expectedBody,
)
}
// Follow a tag we don't already follow.
func (suite *TagsTestSuite) TestFollow() {
accountFixtureName := "local_account_2"
testTag := suite.testTags["welcome"]
apiTag, err := suite.follow(accountFixtureName, testTag.Name, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(testTag.Name, apiTag.Name)
if suite.NotNil(apiTag.Following) {
suite.True(*apiTag.Following)
}
}
// When we follow a tag already followed by the account, it should succeed.
func (suite *TagsTestSuite) TestFollowIdempotent() {
accountFixtureName := "local_account_2"
testAccount := suite.testAccounts[accountFixtureName]
testTag := suite.testTags["welcome"]
// Setup: follow an existing tag.
if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
suite.FailNow(err.Error())
}
// Follow it again through the API.
apiTag, err := suite.follow(accountFixtureName, testTag.Name, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(testTag.Name, apiTag.Name)
if suite.NotNil(apiTag.Following) {
suite.True(*apiTag.Following)
}
}

View file

@ -0,0 +1,89 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package tags
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// TagGETHandler swagger:operation GET /api/v1/tags/{tag_name} getTag
//
// Get details for a hashtag, including whether you currently follow it.
//
// If the tag does not exist, this method will not create it in the database.
//
// ---
// tags:
// - tags
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - read:follows
//
// parameters:
// -
// name: tag_name
// type: string
// description: Name of the tag (no leading `#`)
// in: path
// required: true
//
// responses:
// '200':
// description: "Info about the tag."
// schema:
// "$ref": "#/definitions/tag"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) TagGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiTag, errWithCode := m.processor.Tags().Get(c.Request.Context(), authed.Account, name)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiTag)
}

View file

@ -0,0 +1,93 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package tags_test
import (
"context"
"net/http"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
)
// tagAction follows or unfollows a tag.
func (suite *TagsTestSuite) get(
accountFixtureName string,
tagName string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.Tag, error) {
return suite.tagAction(
accountFixtureName,
tagName,
http.MethodGet,
tags.TagPath,
suite.tagsModule.TagGETHandler,
expectedHTTPStatus,
expectedBody,
)
}
// Get a tag followed by the account.
func (suite *TagsTestSuite) TestGetFollowed() {
accountFixtureName := "local_account_2"
testAccount := suite.testAccounts[accountFixtureName]
testTag := suite.testTags["welcome"]
// Setup: follow an existing tag.
if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
suite.FailNow(err.Error())
}
// Get it through the API.
apiTag, err := suite.get(accountFixtureName, testTag.Name, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(testTag.Name, apiTag.Name)
if suite.NotNil(apiTag.Following) {
suite.True(*apiTag.Following)
}
}
// Get a tag not followed by the account.
func (suite *TagsTestSuite) TestGetUnfollowed() {
accountFixtureName := "local_account_2"
testTag := suite.testTags["Hashtag"]
apiTag, err := suite.get(accountFixtureName, testTag.Name, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(testTag.Name, apiTag.Name)
if suite.NotNil(apiTag.Following) {
suite.False(*apiTag.Following)
}
}
// Get a tag that does not exist, which should result in a 404.
func (suite *TagsTestSuite) TestGetNotFound() {
accountFixtureName := "local_account_2"
_, err := suite.get(accountFixtureName, "THIS_TAG_DOES_NOT_EXIST", http.StatusNotFound, "")
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,49 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package tags
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing"
)
const (
BasePath = "/v1/tags"
TagPath = BasePath + "/:" + apiutil.TagNameKey
FollowPath = TagPath + "/follow"
UnfollowPath = TagPath + "/unfollow"
)
type Module struct {
processor *processing.Processor
}
func New(processor *processing.Processor) *Module {
return &Module{
processor: processor,
}
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodGet, TagPath, m.TagGETHandler)
attachHandler(http.MethodPost, FollowPath, m.FollowTagPOSTHandler)
attachHandler(http.MethodPost, UnfollowPath, m.UnfollowTagPOSTHandler)
}

View file

@ -0,0 +1,179 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package tags_test
import (
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type TagsTestSuite struct {
suite.Suite
db db.DB
storage *storage.Driver
mediaManager *media.Manager
federator *federation.Federator
processor *processing.Processor
emailSender email.Sender
sentEmails map[string]string
state state.State
// standard suite models
testTokens map[string]*gtsmodel.Token
testClients map[string]*gtsmodel.Client
testApplications map[string]*gtsmodel.Application
testUsers map[string]*gtsmodel.User
testAccounts map[string]*gtsmodel.Account
testTags map[string]*gtsmodel.Tag
// module being tested
tagsModule *tags.Module
}
func (suite *TagsTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
suite.testTags = testrig.NewTestTags()
}
func (suite *TagsTestSuite) SetupTest() {
suite.state.Caches.Init()
testrig.StartNoopWorkers(&suite.state)
testrig.InitTestConfig()
config.Config(func(cfg *config.Configuration) {
cfg.WebAssetBaseDir = "../../../../web/assets/"
cfg.WebTemplateBaseDir = "../../../../web/templates/"
})
testrig.InitTestLog()
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.storage = testrig.NewInMemoryStorage()
suite.state.Storage = suite.storage
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
suite.tagsModule = tags.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
}
func (suite *TagsTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
testrig.StandardStorageTeardown(suite.storage)
testrig.StopWorkers(&suite.state)
}
// tagAction gets, follows, or unfollows a tag, returning the tag.
func (suite *TagsTestSuite) tagAction(
accountFixtureName string,
tagName string,
method string,
path string,
handler func(c *gin.Context),
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.Tag, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[accountFixtureName]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
// create the request
url := config.GetProtocol() + "://" + config.GetHost() + "/api/" + path
ctx.Request = httptest.NewRequest(
method,
strings.Replace(url, ":tag_name", tagName, 1),
nil,
)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("tag_name", tagName)
// trigger the handler
handler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
return nil, err
}
errs := gtserror.NewMultiError(2)
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs.Appendf("expected %s got %s", expectedBody, string(b))
}
return nil, errs.Combine()
}
resp := &apimodel.Tag{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func TestTagsTestSuite(t *testing.T) {
suite.Run(t, new(TagsTestSuite))
}

View file

@ -0,0 +1,94 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package tags
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// UnfollowTagPOSTHandler swagger:operation POST /api/v1/tags/{tag_name}/unfollow unfollowTag
//
// Unfollow a hashtag.
//
// Idempotent: if you are not following the tag, this call will still succeed.
//
// ---
// tags:
// - tags
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - write:follows
//
// parameters:
// -
// name: tag_name
// type: string
// description: Name of the tag (no leading `#`)
// in: path
// required: true
//
// responses:
// '200':
// description: "Info about the tag."
// schema:
// "$ref": "#/definitions/tag"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: unauthorized
// '500':
// description: internal server error
func (m *Module) UnfollowTagPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiTag, errWithCode := m.processor.Tags().Unfollow(c.Request.Context(), authed.Account, name)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiTag)
}

View file

@ -0,0 +1,82 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package tags_test
import (
"context"
"net/http"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
)
func (suite *TagsTestSuite) unfollow(
accountFixtureName string,
tagName string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.Tag, error) {
return suite.tagAction(
accountFixtureName,
tagName,
http.MethodPost,
tags.UnfollowPath,
suite.tagsModule.UnfollowTagPOSTHandler,
expectedHTTPStatus,
expectedBody,
)
}
// Unfollow a tag that we follow.
func (suite *TagsTestSuite) TestUnfollow() {
accountFixtureName := "local_account_2"
testAccount := suite.testAccounts[accountFixtureName]
testTag := suite.testTags["welcome"]
// Setup: follow an existing tag.
if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
suite.FailNow(err.Error())
}
// Unfollow it through the API.
apiTag, err := suite.unfollow(accountFixtureName, testTag.Name, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(testTag.Name, apiTag.Name)
if suite.NotNil(apiTag.Following) {
suite.False(*apiTag.Following)
}
}
// When we unfollow a tag not followed by the account, it should succeed.
func (suite *TagsTestSuite) TestUnfollowIdempotent() {
accountFixtureName := "local_account_2"
testTag := suite.testTags["Hashtag"]
apiTag, err := suite.unfollow(accountFixtureName, testTag.Name, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(testTag.Name, apiTag.Name)
if suite.NotNil(apiTag.Following) {
suite.False(*apiTag.Following)
}
}

View file

@ -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"`
}

View file

@ -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)

28
internal/cache/db.go vendored
View file

@ -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.

View file

@ -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"`

View file

@ -157,6 +157,7 @@
// 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 @@
StatusFaveMemRatio: 2,
StatusFaveIDsMemRatio: 3,
TagMemRatio: 2,
TagIDsFollowedByAccountMemRatio: 1,
ThreadMuteMemRatio: 0.2,
TokenMemRatio: 0.75,
TombstoneMemRatio: 0.5,

View file

@ -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()

View file

@ -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 <http://www.gnu.org/licenses/>.
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(&gtsmodel.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)
}
}

View file

@ -19,9 +19,13 @@
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(&gtsmodel.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
}

View file

@ -21,6 +21,7 @@
"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)
}

View file

@ -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"`
}

View file

@ -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)

View file

@ -42,6 +42,7 @@
"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)

View file

@ -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,

View file

@ -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 <http://www.gnu.org/licenses/>.
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 = &gtsmodel.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)
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}

View file

@ -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 = &gtsmodel.ThreadMute{
ID: "01HD3KRMBB1M85QRWHD912QWRE",
@ -488,6 +493,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() {
suite.testStatuses["local_account_1_status_1"],
nil,
false,
nil,
)
threadMute = &gtsmodel.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)

View file

@ -30,10 +30,12 @@
"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, &gtsmodel.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()
}

View file

@ -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

View file

@ -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,