status boosts (#16)

This commit is contained in:
Tobi Smethurst 2021-05-08 15:16:24 +02:00 committed by GitHub
parent 6f5c045284
commit 3c539cdfd6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 447 additions and 1 deletions

View file

@ -0,0 +1,60 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package status
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// StatusBoostPOSTHandler handles boost requests against a given status ID
func (m *Module) StatusBoostPOSTHandler(c *gin.Context) {
l := m.log.WithFields(logrus.Fields{
"func": "StatusBoostPOSTHandler",
"request_uri": c.Request.RequestURI,
"user_agent": c.Request.UserAgent(),
"origin_ip": c.ClientIP(),
})
l.Debugf("entering function")
authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
if err != nil {
l.Debug("not authed so can't boost status")
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
return
}
targetStatusID := c.Param(IDKey)
if targetStatusID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
return
}
mastoStatus, errWithCode := m.processor.StatusBoost(authed, targetStatusID)
if errWithCode != nil {
l.Debugf("error processing status boost: %s", errWithCode.Error())
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
return
}
c.JSON(http.StatusOK, mastoStatus)
}

View file

@ -0,0 +1,210 @@
/*
GoToSocial
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package status_test
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
"github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type StatusBoostTestSuite struct {
StatusStandardTestSuite
}
func (suite *StatusBoostTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
suite.testAttachments = testrig.NewTestAttachments()
suite.testStatuses = testrig.NewTestStatuses()
}
func (suite *StatusBoostTestSuite) SetupTest() {
suite.config = testrig.NewTestConfig()
suite.db = testrig.NewTestDB()
suite.storage = testrig.NewTestStorage()
suite.log = testrig.NewTestLog()
suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
testrig.StandardDBSetup(suite.db)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
}
func (suite *StatusBoostTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
testrig.StandardStorageTeardown(suite.storage)
}
func (suite *StatusBoostTestSuite) TestPostBoost() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.TokenToOauthToken(t)
targetStatus := suite.testStatuses["admin_account_status_1"]
// setup
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
// normally the router would populate these params from the path values,
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{
gin.Param{
Key: status.IDKey,
Value: targetStatus.ID,
},
}
suite.statusModule.StatusBoostPOSTHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
statusReply := &model.Status{}
err = json.Unmarshal(b, statusReply)
assert.NoError(suite.T(), err)
assert.False(suite.T(), statusReply.Sensitive)
assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
assert.Equal(suite.T(), "the_mighty_zork", statusReply.Account.Username)
assert.Len(suite.T(), statusReply.MediaAttachments, 0)
assert.Len(suite.T(), statusReply.Mentions, 0)
assert.Len(suite.T(), statusReply.Emojis, 0)
assert.Len(suite.T(), statusReply.Tags, 0)
assert.NotNil(suite.T(), statusReply.Application)
assert.Equal(suite.T(), "really cool gts application", statusReply.Application.Name)
assert.NotNil(suite.T(), statusReply.Reblog)
assert.Equal(suite.T(), 1, statusReply.Reblog.ReblogsCount)
assert.Equal(suite.T(), 1, statusReply.Reblog.FavouritesCount)
assert.Equal(suite.T(), targetStatus.Content, statusReply.Reblog.Content)
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.Reblog.SpoilerText)
assert.Equal(suite.T(), targetStatus.AccountID, statusReply.Reblog.Account.ID)
assert.Len(suite.T(), statusReply.Reblog.MediaAttachments, 1)
assert.Len(suite.T(), statusReply.Reblog.Tags, 1)
assert.Len(suite.T(), statusReply.Reblog.Emojis, 1)
assert.Equal(suite.T(), "superseriousbusiness", statusReply.Reblog.Application.Name)
}
// try to boost a status that's not boostable
func (suite *StatusBoostTestSuite) TestPostUnboostable() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.TokenToOauthToken(t)
targetStatus := suite.testStatuses["local_account_2_status_4"]
// setup
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
// normally the router would populate these params from the path values,
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{
gin.Param{
Key: status.IDKey,
Value: targetStatus.ID,
},
}
suite.statusModule.StatusBoostPOSTHandler(ctx)
// check response
suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unboostable statuses
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), `{"error":"forbidden"}`, string(b))
}
// try to boost a status that's not visible to the user
func (suite *StatusBoostTestSuite) TestPostNotVisible() {
t := suite.testTokens["local_account_2"]
oauthToken := oauth.TokenToOauthToken(t)
targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals
// setup
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
// normally the router would populate these params from the path values,
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{
gin.Param{
Key: status.IDKey,
Value: targetStatus.ID,
},
}
suite.statusModule.StatusBoostPOSTHandler(ctx)
// check response
suite.EqualValues(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), `{"error":"404 not found"}`, string(b))
}
func TestStatusBoostTestSuite(t *testing.T) {
suite.Run(t, new(StatusBoostTestSuite))
}

View file

@ -89,6 +89,10 @@ type Status struct {
GTSReplyToStatus *Status `pg:"-"`
// Account being replied to
GTSReplyToAccount *Account `pg:"-"`
// Status being boosted
GTSBoostedStatus *Status `pg:"-"`
// Account of the boosted status
GTSBoostedAccount *Account `pg:"-"`
}
// Visibility represents the visibility granularity of a status.

View file

@ -77,6 +77,8 @@ type Processor interface {
StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
// StatusFave processes the faving of a given status, returning the updated status if the fave goes through.
StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
// StatusBoost processes the boost/reblog of a given status, returning the newly-created boost if all is well.
StatusBoost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, ErrorWithCode)
// StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings.
StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error)
// StatusGet gets the given status, taking account of privacy settings and blocks etc.

View file

@ -180,6 +180,104 @@ func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apim
return mastoStatus, nil
}
func (p *processor) StatusBoost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, ErrorWithCode) {
l := p.log.WithField("func", "StatusBoost")
l.Tracef("going to search for target status %s", targetStatusID)
targetStatus := &gtsmodel.Status{}
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
return nil, NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err))
}
l.Tracef("going to search for target account %s", targetStatus.AccountID)
targetAccount := &gtsmodel.Account{}
if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
return nil, NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err))
}
l.Trace("going to get relevant accounts")
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
if err != nil {
return nil, NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err))
}
l.Trace("going to see if status is visible")
visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
if err != nil {
return nil, NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err))
}
if !visible {
return nil, NewErrorNotFound(errors.New("status is not visible"))
}
if !targetStatus.VisibilityAdvanced.Boostable {
return nil, NewErrorForbidden(errors.New("status is not boostable"))
}
// it's visible! it's boostable! so let's boost the FUCK out of it
// first we create a new status and add some basic info to it -- this will be the wrapper for the boosted status
// the wrapper won't use the same ID as the boosted status so we generate some new UUIDs
uris := util.GenerateURIsForAccount(authed.Account.Username, p.config.Protocol, p.config.Host)
boostWrapperStatusID := uuid.NewString()
boostWrapperStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, boostWrapperStatusID)
boostWrapperStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, boostWrapperStatusID)
boostWrapperStatus := &gtsmodel.Status{
ID: boostWrapperStatusID,
URI: boostWrapperStatusURI,
URL: boostWrapperStatusURL,
// the boosted status is not created now, but the boost certainly is
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Local: true, // always local since this is being done through the client API
AccountID: authed.Account.ID,
CreatedWithApplicationID: authed.Application.ID,
// replies can be boosted, but boosts are never replies
InReplyToID: "",
InReplyToAccountID: "",
// these will all be wrapped in the boosted status so set them empty here
Attachments: []string{},
Tags: []string{},
Mentions: []string{},
Emojis: []string{},
// the below fields will be taken from the target status
Content: util.HTMLFormat(targetStatus.Content),
ContentWarning: targetStatus.ContentWarning,
ActivityStreamsType: targetStatus.ActivityStreamsType,
Sensitive: targetStatus.Sensitive,
Language: targetStatus.Language,
Text: targetStatus.Text,
BoostOfID: targetStatus.ID,
Visibility: targetStatus.Visibility,
VisibilityAdvanced: targetStatus.VisibilityAdvanced,
// attach these here for convenience -- the boosted status/account won't go in the DB
// but they're needed in the processor and for the frontend. Since we have them, we can
// attach them so we don't need to fetch them again later (save some DB calls)
GTSBoostedStatus: targetStatus,
GTSBoostedAccount: targetAccount,
}
// put the boost in the database
if err := p.db.Put(boostWrapperStatus); err != nil {
return nil, NewErrorInternalError(err)
}
// return the frontend representation of the new status to the submitter
mastoStatus, err := p.tc.StatusToMasto(boostWrapperStatus, authed.Account, authed.Account, targetAccount, nil, targetStatus)
if err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err))
}
return mastoStatus, nil
}
func (p *processor) StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) {
l := p.log.WithField("func", "StatusFavedBy")

View file

@ -326,7 +326,55 @@ func (c *converter) StatusToMasto(
}
}
var mastoRebloggedStatus *model.Status // TODO
var mastoRebloggedStatus *model.Status
if s.BoostOfID != "" {
// the boosted status might have been set on this struct already so check first before doing db calls
var gtsBoostedStatus *gtsmodel.Status
if s.GTSBoostedStatus != nil {
// it's set, great!
gtsBoostedStatus = s.GTSBoostedStatus
} else {
// it's not set so fetch it from the db
gtsBoostedStatus = &gtsmodel.Status{}
if err := c.db.GetByID(s.BoostOfID, gtsBoostedStatus); err != nil {
return nil, fmt.Errorf("error getting boosted status with id %s: %s", s.BoostOfID, err)
}
}
// the boosted account might have been set on this struct already or passed as a param so check first before doing db calls
var gtsBoostedAccount *gtsmodel.Account
if s.GTSBoostedAccount != nil {
// it's set, great!
gtsBoostedAccount = s.GTSBoostedAccount
} else if boostOfAccount != nil {
// it's been given as a param, great!
gtsBoostedAccount = boostOfAccount
} else if boostOfAccount == nil && s.GTSBoostedAccount == nil {
// it's not set so fetch it from the db
gtsBoostedAccount = &gtsmodel.Account{}
if err := c.db.GetByID(gtsBoostedStatus.AccountID, gtsBoostedAccount); err != nil {
return nil, fmt.Errorf("error getting boosted account %s from status with id %s: %s", gtsBoostedStatus.AccountID, s.BoostOfID, err)
}
}
// the boosted status might be a reply so check this
var gtsBoostedReplyToAccount *gtsmodel.Account
if gtsBoostedStatus.InReplyToAccountID != "" {
gtsBoostedReplyToAccount = &gtsmodel.Account{}
if err := c.db.GetByID(gtsBoostedStatus.InReplyToAccountID, gtsBoostedReplyToAccount); err != nil {
return nil, fmt.Errorf("error getting account that boosted status was a reply to: %s", err)
}
}
if gtsBoostedStatus != nil || gtsBoostedAccount != nil {
mastoRebloggedStatus, err = c.StatusToMasto(gtsBoostedStatus, gtsBoostedAccount, requestingAccount, nil, gtsBoostedReplyToAccount, nil)
if err != nil {
return nil, fmt.Errorf("error converting boosted status to mastotype: %s", err)
}
} else {
return nil, fmt.Errorf("boost of id was set to %s but that status or account was nil", s.BoostOfID)
}
}
var mastoApplication *model.Application
if s.CreatedWithApplicationID != "" {

View file

@ -981,6 +981,30 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
},
ActivityStreamsType: gtsmodel.ActivityStreamsNote,
},
"local_account_2_status_4": {
ID: "57e41a35-20da-4bc9-9cfd-db2089f924db",
URI: "http://localhost:8080/users/1happyturtle/statuses/57e41a35-20da-4bc9-9cfd-db2089f924db",
URL: "http://localhost:8080/@1happyturtle/statuses/57e41a35-20da-4bc9-9cfd-db2089f924db",
Content: "🐢 this is a public status but I want it local only and not boostable 🐢",
CreatedAt: time.Now().Add(-1 * time.Minute),
UpdatedAt: time.Now().Add(-1 * time.Minute),
Local: true,
AccountID: "eecaad73-5703-426d-9312-276641daa31e",
InReplyToID: "",
BoostOfID: "",
ContentWarning: "",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: true,
Language: "en",
CreatedWithApplicationID: "6b0cd164-8497-4cd5-bec9-957886fac5df",
VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{
Federated: false,
Boostable: false,
Replyable: true,
Likeable: true,
},
ActivityStreamsType: gtsmodel.ActivityStreamsNote,
},
}
}