[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
37 changed files with 2820 additions and 46 deletions

View File

@ -32,6 +32,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
"github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies"
@ -46,6 +47,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/search"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
"github.com/superseriousbusiness/gotosocial/internal/api/client/timelines"
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -59,7 +61,7 @@ type Client struct {
processor *processing.Processor
db db.DB
accounts *accounts.Module // api/v1/accounts
accounts *accounts.Module // api/v1/accounts, api/v1/profile
admin *admin.Module // api/v1/admin
apps *apps.Module // api/v1/apps
blocks *blocks.Module // api/v1/blocks
@ -71,6 +73,7 @@ type Client struct {
filtersV1 *filtersV1.Module // api/v1/filters
filtersV2 *filtersV2.Module // api/v2/filters
followRequests *followrequests.Module // api/v1/follow_requests
followedTags *followedtags.Module // api/v1/followed_tags
instance *instance.Module // api/v1/instance
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
lists *lists.Module // api/v1/lists
@ -84,6 +87,7 @@ type Client struct {
search *search.Module // api/v1/search, api/v2/search
statuses *statuses.Module // api/v1/statuses
streaming *streaming.Module // api/v1/streaming
tags *tags.Module // api/v1/tags
timelines *timelines.Module // api/v1/timelines
user *user.Module // api/v1/user
}
@ -117,6 +121,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.filtersV1.Route(h)
c.filtersV2.Route(h)
c.followRequests.Route(h)
c.followedTags.Route(h)
c.instance.Route(h)
c.interactionPolicies.Route(h)
c.lists.Route(h)
@ -130,6 +135,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.search.Route(h)
c.statuses.Route(h)
c.streaming.Route(h)
c.tags.Route(h)
c.timelines.Route(h)
c.user.Route(h)
}
@ -151,6 +157,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
filtersV1: filtersV1.New(p),
filtersV2: filtersV2.New(p),
followRequests: followrequests.New(p),
followedTags: followedtags.New(p),
instance: instance.New(p),
interactionPolicies: interactionpolicies.New(p),
lists: lists.New(p),
@ -164,6 +171,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
search: search.New(p),
statuses: statuses.New(p),
streaming: streaming.New(p, time.Second*30, 4096),
tags: tags.New(p),
timelines: timelines.New(p),
user: user.New(p),
}

View File

@ -34,7 +34,7 @@ import (
//
// ---
// 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"`
}