[feature] Allow implicit accept of pending replies

This commit is contained in:
tobi 2024-09-20 17:03:34 +02:00
commit b0cf28da45
15 changed files with 1318 additions and 377 deletions

View file

@ -18,6 +18,12 @@
package statuses_test package statuses_test
import ( import (
"bytes"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
@ -25,6 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/state"
@ -59,6 +66,113 @@ type StatusStandardTestSuite struct {
statusModule *statuses.Module statusModule *statuses.Module
} }
// Normalizes a status response to a determinate
// form, and pretty-prints it to JSON.
func (suite *StatusStandardTestSuite) parseStatusResponse(
recorder *httptest.ResponseRecorder,
) (string, *httptest.ResponseRecorder) {
result := recorder.Result()
defer result.Body.Close()
data, err := io.ReadAll(result.Body)
if err != nil {
suite.FailNow(err.Error())
}
rawMap := make(map[string]any)
if err := json.Unmarshal(data, &rawMap); err != nil {
suite.FailNow(err.Error())
}
// Make status fields determinate.
suite.determinateStatus(rawMap)
// For readability, don't
// escape HTML, and indent json.
out := new(bytes.Buffer)
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(&rawMap); err != nil {
suite.FailNow(err.Error())
}
return strings.TrimSpace(out.String()), recorder
}
func (suite *StatusStandardTestSuite) determinateStatus(rawMap map[string]any) {
// Replace any fields from the raw map that
// aren't determinate (date, id, url, etc).
if _, ok := rawMap["id"]; ok {
rawMap["id"] = id.Highest
}
if _, ok := rawMap["uri"]; ok {
rawMap["uri"] = "http://localhost:8080/some/determinate/url"
}
if _, ok := rawMap["url"]; ok {
rawMap["url"] = "http://localhost:8080/some/determinate/url"
}
if _, ok := rawMap["created_at"]; ok {
rawMap["created_at"] = "right the hell just now babyee"
}
// Make ID of any mentions determinate.
if menchiesRaw, ok := rawMap["mentions"]; ok {
menchies, ok := menchiesRaw.([]any)
if !ok {
suite.FailNow("couldn't coerce menchies")
}
for _, menchieRaw := range menchies {
menchie, ok := menchieRaw.(map[string]any)
if !ok {
suite.FailNow("couldn't coerce menchie")
}
if _, ok := menchie["id"]; ok {
menchie["id"] = id.Highest
}
}
}
// Make fields of any poll determinate.
if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil {
poll, ok := pollRaw.(map[string]any)
if !ok {
suite.FailNow("couldn't coerce poll")
}
if _, ok := poll["id"]; ok {
poll["id"] = id.Highest
}
if _, ok := poll["expires_at"]; ok {
poll["expires_at"] = "ah like you know whatever dude it's chill"
}
}
// Replace account since that's not really
// what we care about for these tests.
if _, ok := rawMap["account"]; ok {
rawMap["account"] = "yeah this is my account, what about it punk"
}
// If status contains an embedded
// reblog do the same thing for that.
if reblogRaw, ok := rawMap["reblog"]; ok && reblogRaw != nil {
reblog, ok := reblogRaw.(map[string]any)
if !ok {
suite.FailNow("couldn't coerce reblog")
}
suite.determinateStatus(reblog)
}
}
func (suite *StatusStandardTestSuite) SetupSuite() { func (suite *StatusStandardTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens() suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients() suite.testClients = testrig.NewTestClients()

View file

@ -17,9 +17,6 @@ package statuses_test
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@ -28,7 +25,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
@ -38,212 +35,596 @@ type StatusBoostTestSuite struct {
StatusStandardTestSuite StatusStandardTestSuite
} }
func (suite *StatusBoostTestSuite) TestPostBoost() { func (suite *StatusBoostTestSuite) postStatusBoost(
t := suite.testTokens["local_account_1"] targetStatusID string,
oauthToken := oauth.DBTokenToToken(t) app *gtsmodel.Application,
token *gtsmodel.Token,
targetStatus := suite.testStatuses["admin_account_status_1"] user *gtsmodel.User,
account *gtsmodel.Account,
// setup ) (string, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil) ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) ctx.Set(oauth.SessionAuthorizedApplication, app)
ctx.Set(oauth.SessionAuthorizedToken, oauthToken) ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) ctx.Set(oauth.SessionAuthorizedUser, user)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) ctx.Set(oauth.SessionAuthorizedAccount, account)
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
const pathBase = "http://localhost:8080/api" + statuses.ReblogPath
path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
ctx.Request.Header.Set("accept", "application/json") ctx.Request.Header.Set("accept", "application/json")
// normally the router would populate these params from the path values, // Populate target status ID.
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{ ctx.Params = gin.Params{
gin.Param{ gin.Param{
Key: statuses.IDKey, Key: apiutil.IDKey,
Value: targetStatus.ID, Value: targetStatusID,
}, },
} }
// Trigger handler.
suite.statusModule.StatusBoostPOSTHandler(ctx) suite.statusModule.StatusBoostPOSTHandler(ctx)
return suite.parseStatusResponse(recorder)
}
// check response func (suite *StatusBoostTestSuite) TestPostBoost() {
suite.EqualValues(http.StatusOK, recorder.Code) var (
targetStatus = suite.testStatuses["admin_account_status_1"]
app = suite.testApplications["application_1"]
token = suite.testTokens["local_account_1"]
user = suite.testUsers["local_account_1"]
account = suite.testAccounts["local_account_1"]
)
result := recorder.Result() out, recorder := suite.postStatusBoost(
defer result.Body.Close() targetStatus.ID,
b, err := ioutil.ReadAll(result.Body) app,
suite.NoError(err) token,
user,
account,
)
statusReply := &apimodel.Status{} // We should have OK from
err = json.Unmarshal(b, statusReply) // our call to the function.
suite.NoError(err) suite.Equal(http.StatusOK, recorder.Code)
suite.False(statusReply.Sensitive) // Target status should now
suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility) // be "reblogged" by us.
suite.Equal(`{
suite.Empty(statusReply.SpoilerText) "account": "yeah this is my account, what about it punk",
suite.Empty(statusReply.Content) "application": {
suite.Equal("the_mighty_zork", statusReply.Account.Username) "name": "really cool gts application",
suite.Len(statusReply.MediaAttachments, 0) "website": "https://reallycool.app"
suite.Len(statusReply.Mentions, 0) },
suite.Len(statusReply.Emojis, 0) "bookmarked": true,
suite.Len(statusReply.Tags, 0) "card": null,
"content": "",
suite.NotNil(statusReply.Application) "created_at": "right the hell just now babyee",
suite.Equal("really cool gts application", statusReply.Application.Name) "emojis": [],
"favourited": true,
suite.NotNil(statusReply.Reblog) "favourites_count": 0,
suite.Equal(1, statusReply.Reblog.ReblogsCount) "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
suite.Equal(1, statusReply.Reblog.FavouritesCount) "in_reply_to_account_id": null,
suite.Equal(targetStatus.Content, statusReply.Reblog.Content) "in_reply_to_id": null,
suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText) "interaction_policy": {
suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID) "can_favourite": {
suite.Len(statusReply.Reblog.MediaAttachments, 1) "always": [
suite.Len(statusReply.Reblog.Tags, 1) "public",
suite.Len(statusReply.Reblog.Emojis, 1) "me"
suite.True(statusReply.Reblogged) ],
suite.True(statusReply.Reblog.Reblogged) "with_approval": []
suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name) },
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
}
},
"language": null,
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": false,
"poll": null,
"reblog": {
"account": "yeah this is my account, what about it punk",
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"bookmarked": true,
"card": null,
"content": "hello world! #welcome ! first post on the instance :rainbow: !",
"created_at": "right the hell just now babyee",
"emojis": [
{
"category": "reactions",
"shortcode": "rainbow",
"static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
"visible_in_picker": true
}
],
"favourited": true,
"favourites_count": 1,
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
}
},
"language": "en",
"media_attachments": [
{
"blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj",
"description": "Black and white image of some 50's style text saying: Welcome On Board",
"id": "01F8MH6NEM8D7527KZAECTCR76",
"meta": {
"focus": {
"x": 0,
"y": 0
},
"original": {
"aspect": 1.9047619,
"height": 630,
"size": "1200x630",
"width": 1200
},
"small": {
"aspect": 1.9104477,
"height": 268,
"size": "512x268",
"width": 512
}
},
"preview_remote_url": null,
"preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp",
"remote_url": null,
"text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
"type": "image",
"url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg"
}
],
"mentions": [],
"muted": false,
"pinned": false,
"poll": null,
"reblog": null,
"reblogged": true,
"reblogs_count": 1,
"replies_count": 1,
"sensitive": false,
"spoiler_text": "",
"tags": [
{
"name": "welcome",
"url": "http://localhost:8080/tags/welcome"
}
],
"text": "hello world! #welcome ! first post on the instance :rainbow: !",
"uri": "http://localhost:8080/some/determinate/url",
"url": "http://localhost:8080/some/determinate/url",
"visibility": "public"
},
"reblogged": true,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"uri": "http://localhost:8080/some/determinate/url",
"url": "http://localhost:8080/some/determinate/url",
"visibility": "public"
}`, out)
} }
func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() { func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() {
t := suite.testTokens["local_account_1"] var (
oauthToken := oauth.DBTokenToToken(t) targetStatus = suite.testStatuses["local_account_1_status_5"]
app = suite.testApplications["application_1"]
token = suite.testTokens["local_account_1"]
user = suite.testUsers["local_account_1"]
account = suite.testAccounts["local_account_1"]
)
testStatus := suite.testStatuses["local_account_1_status_5"] out, recorder := suite.postStatusBoost(
testAccount := suite.testAccounts["local_account_1"] targetStatus.ID,
testUser := suite.testUsers["local_account_1"] app,
token,
user,
account,
)
recorder := httptest.NewRecorder() // We should have OK from
ctx, _ := testrig.CreateGinTestContext(recorder, nil) // our call to the function.
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) suite.Equal(http.StatusOK, recorder.Code)
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, testUser)
ctx.Set(oauth.SessionAuthorizedAccount, testAccount)
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", testStatus.ID, 1)), nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.Params = gin.Params{ // Target status should now
gin.Param{ // be "reblogged" by us.
Key: statuses.IDKey, suite.Equal(`{
Value: testStatus.ID, "account": "yeah this is my account, what about it punk",
}, "application": {
} "name": "really cool gts application",
"website": "https://reallycool.app"
suite.statusModule.StatusBoostPOSTHandler(ctx) },
"bookmarked": false,
// check response "card": null,
suite.EqualValues(http.StatusOK, recorder.Code) "content": "",
"created_at": "right the hell just now babyee",
result := recorder.Result() "emojis": [],
defer result.Body.Close() "favourited": false,
b, err := ioutil.ReadAll(result.Body) "favourites_count": 0,
suite.NoError(err) "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"in_reply_to_account_id": null,
responseStatus := &apimodel.Status{} "in_reply_to_id": null,
err = json.Unmarshal(b, responseStatus) "interaction_policy": {
suite.NoError(err) "can_favourite": {
"always": [
suite.False(responseStatus.Sensitive) "author",
suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility) "followers",
"mentioned",
suite.Empty(responseStatus.SpoilerText) "me"
suite.Empty(responseStatus.Content) ],
suite.Equal("the_mighty_zork", responseStatus.Account.Username) "with_approval": []
suite.Len(responseStatus.MediaAttachments, 0) },
suite.Len(responseStatus.Mentions, 0) "can_reblog": {
suite.Len(responseStatus.Emojis, 0) "always": [
suite.Len(responseStatus.Tags, 0) "author",
"me"
suite.NotNil(responseStatus.Application) ],
suite.Equal("really cool gts application", responseStatus.Application.Name) "with_approval": []
},
suite.NotNil(responseStatus.Reblog) "can_reply": {
suite.Equal(1, responseStatus.Reblog.ReblogsCount) "always": [
suite.Equal(0, responseStatus.Reblog.FavouritesCount) "author",
suite.Equal(testStatus.Content, responseStatus.Reblog.Content) "followers",
suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText) "mentioned",
suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID) "me"
suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility) ],
suite.Empty(responseStatus.Reblog.MediaAttachments) "with_approval": []
suite.Empty(responseStatus.Reblog.Tags) }
suite.Empty(responseStatus.Reblog.Emojis) },
suite.True(responseStatus.Reblogged) "language": null,
suite.True(responseStatus.Reblog.Reblogged) "media_attachments": [],
suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name) "mentions": [],
"muted": false,
"pinned": false,
"poll": null,
"reblog": {
"account": "yeah this is my account, what about it punk",
"application": {
"name": "really cool gts application",
"website": "https://reallycool.app"
},
"bookmarked": false,
"card": null,
"content": "hi!",
"created_at": "right the hell just now babyee",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"interaction_policy": {
"can_favourite": {
"always": [
"author",
"followers",
"mentioned",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"author",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"author",
"followers",
"mentioned",
"me"
],
"with_approval": []
}
},
"language": "en",
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": false,
"poll": null,
"reblog": null,
"reblogged": true,
"reblogs_count": 1,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": "hi!",
"uri": "http://localhost:8080/some/determinate/url",
"url": "http://localhost:8080/some/determinate/url",
"visibility": "private"
},
"reblogged": true,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"uri": "http://localhost:8080/some/determinate/url",
"url": "http://localhost:8080/some/determinate/url",
"visibility": "private"
}`, out)
} }
// try to boost a status that's not boostable / visible to us // Try to boost a status that's
// not boostable / visible to us.
func (suite *StatusBoostTestSuite) TestPostUnboostable() { func (suite *StatusBoostTestSuite) TestPostUnboostable() {
t := suite.testTokens["local_account_1"] var (
oauthToken := oauth.DBTokenToToken(t) targetStatus = suite.testStatuses["local_account_2_status_4"]
app = suite.testApplications["application_1"]
token = suite.testTokens["local_account_1"]
user = suite.testUsers["local_account_1"]
account = suite.testAccounts["local_account_1"]
)
targetStatus := suite.testStatuses["local_account_2_status_4"] out, recorder := suite.postStatusBoost(
targetStatus.ID,
app,
token,
user,
account,
)
// setup // We should have 403 from
recorder := httptest.NewRecorder() // our call to the function.
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
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(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
ctx.Request.Header.Set("accept", "application/json")
// 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: statuses.IDKey,
Value: targetStatus.ID,
},
}
suite.statusModule.StatusBoostPOSTHandler(ctx)
// check response
suite.Equal(http.StatusForbidden, recorder.Code) suite.Equal(http.StatusForbidden, recorder.Code)
result := recorder.Result() // We should have a helpful message.
defer result.Body.Close() suite.Equal(`{
b, err := ioutil.ReadAll(result.Body) "error": "Forbidden: you do not have permission to boost this status"
suite.NoError(err) }`, out)
suite.Equal(`{"error":"Forbidden: you do not have permission to boost this status"}`, string(b))
} }
// try to boost a status that's not visible to the user // Try to boost a status that's not visible to the user.
func (suite *StatusBoostTestSuite) TestPostNotVisible() { func (suite *StatusBoostTestSuite) TestPostNotVisible() {
// stop local_account_2 following zork // Stop local_account_2 following zork.
err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, &gtsmodel.Follow{}) err := suite.db.DeleteFollowByID(
suite.NoError(err) context.Background(),
suite.testFollows["local_account_2_local_account_1"].ID,
t := suite.testTokens["local_account_2"] )
oauthToken := oauth.DBTokenToToken(t) if err != nil {
suite.FailNow(err.Error())
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, _ := testrig.CreateGinTestContext(recorder, nil)
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(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
ctx.Request.Header.Set("accept", "application/json")
// 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: statuses.IDKey,
Value: targetStatus.ID,
},
} }
suite.statusModule.StatusBoostPOSTHandler(ctx) var (
// This is a mutual only status and
// these accounts aren't mutuals anymore.
targetStatus = suite.testStatuses["local_account_1_status_3"]
app = suite.testApplications["application_1"]
token = suite.testTokens["local_account_2"]
user = suite.testUsers["local_account_2"]
account = suite.testAccounts["local_account_2"]
)
// check response out, recorder := suite.postStatusBoost(
suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible targetStatus.ID,
app,
token,
user,
account,
)
// We should have 404 from
// our call to the function.
suite.Equal(http.StatusNotFound, recorder.Code)
// We should have a helpful message.
suite.Equal(`{
"error": "Not Found: target status not found"
}`, out)
}
// Boost a status that's pending approval by us.
func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() {
var (
targetStatus = suite.testStatuses["admin_account_status_5"]
app = suite.testApplications["application_1"]
token = suite.testTokens["local_account_2"]
user = suite.testUsers["local_account_2"]
account = suite.testAccounts["local_account_2"]
)
out, recorder := suite.postStatusBoost(
targetStatus.ID,
app,
token,
user,
account,
)
// We should have OK from
// our call to the function.
suite.Equal(http.StatusOK, recorder.Code)
// Target status should now
// be "reblogged" by us.
suite.Equal(`{
"account": "yeah this is my account, what about it punk",
"application": {
"name": "really cool gts application",
"website": "https://reallycool.app"
},
"bookmarked": false,
"card": null,
"content": "",
"created_at": "right the hell just now babyee",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
}
},
"language": null,
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": false,
"poll": null,
"reblog": {
"account": "yeah this is my account, what about it punk",
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"bookmarked": false,
"card": null,
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>",
"created_at": "right the hell just now babyee",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
}
},
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "1happyturtle",
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"url": "http://localhost:8080/@1happyturtle",
"username": "1happyturtle"
}
],
"muted": false,
"pinned": false,
"poll": null,
"reblog": null,
"reblogged": true,
"reblogs_count": 1,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": "Hi @1happyturtle, can I reply?",
"uri": "http://localhost:8080/some/determinate/url",
"url": "http://localhost:8080/some/determinate/url",
"visibility": "unlisted"
},
"reblogged": true,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"uri": "http://localhost:8080/some/determinate/url",
"url": "http://localhost:8080/some/determinate/url",
"visibility": "unlisted"
}`, out)
// Target status should no
// longer be pending approval.
dbStatus, err := suite.state.DB.GetStatusByID(
context.Background(),
targetStatus.ID,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.False(*dbStatus.PendingApproval)
// There should be an Accept
// stored for the target status.
intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI(
context.Background(), targetStatus.URI,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.NotZero(intReq.AcceptedAt)
suite.NotEmpty(intReq.URI)
} }
func TestStatusBoostTestSuite(t *testing.T) { func TestStatusBoostTestSuite(t *testing.T) {

View file

@ -20,18 +20,14 @@ package statuses_test
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -81,91 +77,7 @@ func (suite *StatusCreateTestSuite) postStatus(
// Trigger handler. // Trigger handler.
suite.statusModule.StatusCreatePOSTHandler(ctx) suite.statusModule.StatusCreatePOSTHandler(ctx)
return suite.parseStatusResponse(recorder)
result := recorder.Result()
defer result.Body.Close()
data, err := io.ReadAll(result.Body)
if err != nil {
suite.FailNow(err.Error())
}
rawMap := make(map[string]any)
if err := json.Unmarshal(data, &rawMap); err != nil {
suite.FailNow(err.Error())
}
// Replace any fields from the raw map that
// aren't determinate (date, id, url, etc).
if _, ok := rawMap["id"]; ok {
rawMap["id"] = id.Highest
}
if _, ok := rawMap["uri"]; ok {
rawMap["uri"] = "http://localhost:8080/some/determinate/url"
}
if _, ok := rawMap["url"]; ok {
rawMap["url"] = "http://localhost:8080/some/determinate/url"
}
if _, ok := rawMap["created_at"]; ok {
rawMap["created_at"] = "right the hell just now babyee"
}
// Make ID of any mentions determinate.
if menchiesRaw, ok := rawMap["mentions"]; ok {
menchies, ok := menchiesRaw.([]any)
if !ok {
suite.FailNow("couldn't coerce menchies")
}
for _, menchieRaw := range menchies {
menchie, ok := menchieRaw.(map[string]any)
if !ok {
suite.FailNow("couldn't coerce menchie")
}
if _, ok := menchie["id"]; ok {
menchie["id"] = id.Highest
}
}
}
// Make fields of any poll determinate.
if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil {
poll, ok := pollRaw.(map[string]any)
if !ok {
suite.FailNow("couldn't coerce poll")
}
if _, ok := poll["id"]; ok {
poll["id"] = id.Highest
}
if _, ok := poll["expires_at"]; ok {
poll["expires_at"] = "ah like you know whatever dude it's chill"
}
}
// Replace account since that's not really
// what we care about for these tests.
if _, ok := rawMap["account"]; ok {
rawMap["account"] = "yeah this is my account, what about it punk"
}
// For readability, don't
// escape HTML, and indent json.
out := new(bytes.Buffer)
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(&rawMap); err != nil {
suite.FailNow(err.Error())
}
return strings.TrimSpace(out.String()), recorder
} }
// Post a new status with some custom visibility settings // Post a new status with some custom visibility settings
@ -447,7 +359,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusMessedUpIntPolicy() {
suite.Equal(http.StatusBadRequest, recorder.Code) suite.Equal(http.StatusBadRequest, recorder.Code)
// We should have a helpful error // We should have a helpful error
// message telling us how we screwed up. // message telling us how we screwed up.
suite.Equal(`{ suite.Equal(`{
"error": "Bad Request: error converting followers_only.can_reply.always: policyURI public is not feasible for visibility followers_only" "error": "Bad Request: error converting followers_only.can_reply.always: policyURI public is not feasible for visibility followers_only"
}`, out) }`, out)

View file

@ -18,20 +18,18 @@
package statuses_test package statuses_test
import ( import (
"encoding/json" "context"
"fmt"
"io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -40,90 +38,260 @@ type StatusFaveTestSuite struct {
StatusStandardTestSuite StatusStandardTestSuite
} }
// fave a status func (suite *StatusFaveTestSuite) postStatusFave(
func (suite *StatusFaveTestSuite) TestPostFave() { targetStatusID string,
t := suite.testTokens["local_account_1"] app *gtsmodel.Application,
oauthToken := oauth.DBTokenToToken(t) token *gtsmodel.Token,
user *gtsmodel.User,
targetStatus := suite.testStatuses["admin_account_status_2"] account *gtsmodel.Account,
) (string, *httptest.ResponseRecorder) {
// setup
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil) ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) ctx.Set(oauth.SessionAuthorizedApplication, app)
ctx.Set(oauth.SessionAuthorizedToken, oauthToken) ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) ctx.Set(oauth.SessionAuthorizedUser, user)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) ctx.Set(oauth.SessionAuthorizedAccount, account)
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
const pathBase = "http://localhost:8080/api" + statuses.FavouritePath
path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
ctx.Request.Header.Set("accept", "application/json") ctx.Request.Header.Set("accept", "application/json")
// normally the router would populate these params from the path values, // Populate target status ID.
// but because we're calling the function directly, we need to set them manually.
ctx.Params = gin.Params{ ctx.Params = gin.Params{
gin.Param{ gin.Param{
Key: statuses.IDKey, Key: apiutil.IDKey,
Value: targetStatus.ID, Value: targetStatusID,
}, },
} }
// Trigger handler.
suite.statusModule.StatusFavePOSTHandler(ctx) suite.statusModule.StatusFavePOSTHandler(ctx)
return suite.parseStatusResponse(recorder)
// 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 := &apimodel.Status{}
err = json.Unmarshal(b, statusReply)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
assert.True(suite.T(), statusReply.Sensitive)
assert.Equal(suite.T(), apimodel.VisibilityPublic, statusReply.Visibility)
assert.True(suite.T(), statusReply.Favourited)
assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
} }
// try to fave a status that's not faveable // Fave a status we haven't faved yet.
func (suite *StatusFaveTestSuite) TestPostFave() {
var (
targetStatus = suite.testStatuses["admin_account_status_2"]
app = suite.testApplications["application_1"]
token = suite.testTokens["local_account_1"]
user = suite.testUsers["local_account_1"]
account = suite.testAccounts["local_account_1"]
)
out, recorder := suite.postStatusFave(
targetStatus.ID,
app,
token,
user,
account,
)
// We should have OK from
// our call to the function.
suite.Equal(http.StatusOK, recorder.Code)
// Target status should now
// be "favourited" by us.
suite.Equal(`{
"account": "yeah this is my account, what about it punk",
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"bookmarked": false,
"card": null,
"content": "🐕🐕🐕🐕🐕",
"created_at": "right the hell just now babyee",
"emojis": [],
"favourited": true,
"favourites_count": 1,
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
}
},
"language": "en",
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": false,
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": true,
"spoiler_text": "open to see some puppies",
"tags": [],
"text": "🐕🐕🐕🐕🐕",
"uri": "http://localhost:8080/some/determinate/url",
"url": "http://localhost:8080/some/determinate/url",
"visibility": "public"
}`, out)
}
// Try to fave a status
// that's not faveable by us.
func (suite *StatusFaveTestSuite) TestPostUnfaveable() { func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
t := suite.testTokens["admin_account"] var (
oauthToken := oauth.DBTokenToToken(t) targetStatus = suite.testStatuses["local_account_1_status_3"]
app = suite.testApplications["application_1"]
token = suite.testTokens["admin_account"]
user = suite.testUsers["admin_account"]
account = suite.testAccounts["admin_account"]
)
targetStatus := suite.testStatuses["local_account_1_status_3"] // this one is unlikeable out, recorder := suite.postStatusFave(
targetStatus.ID,
app,
token,
user,
account,
)
// setup // We should have 403 from
recorder := httptest.NewRecorder() // our call to the function.
ctx, _ := testrig.CreateGinTestContext(recorder, nil) suite.Equal(http.StatusForbidden, recorder.Code)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["admin_account"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["admin_account"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
ctx.Request.Header.Set("accept", "application/json")
// normally the router would populate these params from the path values, // We should get a helpful error.
// but because we're calling the function directly, we need to set them manually. suite.Equal(`{
ctx.Params = gin.Params{ "error": "Forbidden: you do not have permission to fave this status"
gin.Param{ }`, out)
Key: statuses.IDKey, }
Value: targetStatus.ID,
}, // Fave a status that's pending approval by us.
func (suite *StatusFaveTestSuite) TestPostFaveImplicitAccept() {
var (
targetStatus = suite.testStatuses["admin_account_status_5"]
app = suite.testApplications["application_1"]
token = suite.testTokens["local_account_2"]
user = suite.testUsers["local_account_2"]
account = suite.testAccounts["local_account_2"]
)
out, recorder := suite.postStatusFave(
targetStatus.ID,
app,
token,
user,
account,
)
// We should have OK from
// our call to the function.
suite.Equal(http.StatusOK, recorder.Code)
// Target status should now
// be "favourited" by us.
suite.Equal(`{
"account": "yeah this is my account, what about it punk",
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"bookmarked": false,
"card": null,
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>",
"created_at": "right the hell just now babyee",
"emojis": [],
"favourited": true,
"favourites_count": 1,
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
}
},
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "1happyturtle",
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
"url": "http://localhost:8080/@1happyturtle",
"username": "1happyturtle"
}
],
"muted": false,
"pinned": false,
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": "Hi @1happyturtle, can I reply?",
"uri": "http://localhost:8080/some/determinate/url",
"url": "http://localhost:8080/some/determinate/url",
"visibility": "unlisted"
}`, out)
// Target status should no
// longer be pending approval.
dbStatus, err := suite.state.DB.GetStatusByID(
context.Background(),
targetStatus.ID,
)
if err != nil {
suite.FailNow(err.Error())
} }
suite.False(*dbStatus.PendingApproval)
suite.statusModule.StatusFavePOSTHandler(ctx) // There should be an Accept
// stored for the target status.
// check response intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI(
suite.EqualValues(http.StatusForbidden, recorder.Code) context.Background(), targetStatus.URI,
)
result := recorder.Result() if err != nil {
defer result.Body.Close() suite.FailNow(err.Error())
b, err := ioutil.ReadAll(result.Body) }
assert.NoError(suite.T(), err) suite.NotZero(intReq.AcceptedAt)
assert.Equal(suite.T(), `{"error":"Forbidden: you do not have permission to fave this status"}`, string(b)) suite.NotEmpty(intReq.URI)
} }
func TestStatusFaveTestSuite(t *testing.T) { func TestStatusFaveTestSuite(t *testing.T) {

View file

@ -223,7 +223,7 @@ func NewProcessor(
processor.tags = tags.New(state, converter) processor.tags = tags.New(state, converter)
processor.timeline = timeline.New(state, converter, visFilter) processor.timeline = timeline.New(state, converter, visFilter)
processor.search = search.New(state, federator, converter, visFilter) processor.search = search.New(state, federator, converter, visFilter)
processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc) processor.status = status.New(state, &common, &processor.polls, &processor.interactionRequests, federator, converter, visFilter, intFilter, parseMentionFunc)
processor.user = user.New(state, converter, oauthServer, emailSender) processor.user = user.New(state, converter, oauthServer, emailSender)
// The advanced migrations processor sequences advanced migrations from all other processors. // The advanced migrations processor sequences advanced migrations from all other processors.

View file

@ -28,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/util"
) )
// BoostCreate processes the boost/reblog of target // BoostCreate processes the boost/reblog of target
@ -138,6 +139,23 @@ func (p *Processor) BoostCreate(
Target: target.Account, Target: target.Account,
}) })
// If the boost target status replies to a status
// that we own, and has a pending interaction
// request, use the boost as an implicit accept.
implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
requester, target,
)
if errWithCode != nil {
return nil, errWithCode
}
// If we ended up implicitly accepting, mark the
// target status as no longer pending approval so
// it's serialized properly via the API.
if implicitlyAccepted {
target.PendingApproval = util.Ptr(false)
}
return p.c.GetAPIStatus(ctx, requester, boost) return p.c.GetAPIStatus(ctx, requester, boost)
} }

View file

@ -164,6 +164,23 @@ func (p *Processor) Create(
} }
} }
// If the new status replies to a status that
// replies to us, use our reply as an implicit
// accept of any pending interaction.
implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
requester, status,
)
if errWithCode != nil {
return nil, errWithCode
}
// If we ended up implicitly accepting, mark the
// replied-to status as no longer pending approval
// so it's serialized properly via the API.
if implicitlyAccepted {
status.InReplyTo.PendingApproval = util.Ptr(false)
}
return p.c.GetAPIStatus(ctx, requester, status) return p.c.GetAPIStatus(ctx, requester, status)
} }

View file

@ -31,6 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util"
) )
func (p *Processor) getFaveableStatus( func (p *Processor) getFaveableStatus(
@ -138,8 +139,6 @@ func (p *Processor) FaveCreate(
pendingApproval = false pendingApproval = false
} }
status.PendingApproval = &pendingApproval
// Create a new fave, marking it // Create a new fave, marking it
// as pending approval if necessary. // as pending approval if necessary.
faveID := id.NewULID() faveID := id.NewULID()
@ -157,7 +156,7 @@ func (p *Processor) FaveCreate(
} }
if err := p.state.DB.PutStatusFave(ctx, gtsFave); err != nil { if err := p.state.DB.PutStatusFave(ctx, gtsFave); err != nil {
err = fmt.Errorf("FaveCreate: error putting fave in database: %w", err) err = gtserror.Newf("db error putting fave: %w", err)
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
@ -170,6 +169,23 @@ func (p *Processor) FaveCreate(
Target: status.Account, Target: status.Account,
}) })
// If the fave target status replies to a status
// that we own, and has a pending interaction
// request, use the fave as an implicit accept.
implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
requester, status,
)
if errWithCode != nil {
return nil, errWithCode
}
// If we ended up implicitly accepting, mark the
// target status as no longer pending approval so
// it's serialized properly via the API.
if implicitlyAccepted {
status.PendingApproval = util.Ptr(false)
}
return p.c.GetAPIStatus(ctx, requester, status) return p.c.GetAPIStatus(ctx, requester, status)
} }

View file

@ -23,6 +23,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests"
"github.com/superseriousbusiness/gotosocial/internal/processing/polls" "github.com/superseriousbusiness/gotosocial/internal/processing/polls"
"github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/text"
@ -42,7 +43,8 @@ type Processor struct {
parseMention gtsmodel.ParseMentionFunc parseMention gtsmodel.ParseMentionFunc
// other processors // other processors
polls *polls.Processor polls *polls.Processor
intReqs *interactionrequests.Processor
} }
// New returns a new status processor. // New returns a new status processor.
@ -50,6 +52,7 @@ func New(
state *state.State, state *state.State,
common *common.Processor, common *common.Processor,
polls *polls.Processor, polls *polls.Processor,
intReqs *interactionrequests.Processor,
federator *federation.Federator, federator *federation.Federator,
converter *typeutils.Converter, converter *typeutils.Converter,
visFilter *visibility.Filter, visFilter *visibility.Filter,
@ -66,5 +69,6 @@ func New(
formatter: text.NewFormatter(state.DB), formatter: text.NewFormatter(state.DB),
parseMention: parseMention, parseMention: parseMention,
polls: polls, polls: polls,
intReqs: intReqs,
} }
} }

View file

@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests"
"github.com/superseriousbusiness/gotosocial/internal/processing/polls" "github.com/superseriousbusiness/gotosocial/internal/processing/polls"
"github.com/superseriousbusiness/gotosocial/internal/processing/status" "github.com/superseriousbusiness/gotosocial/internal/processing/status"
"github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/state"
@ -100,11 +101,13 @@ func (suite *StatusStandardTestSuite) SetupTest() {
common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter) common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter)
polls := polls.New(&common, &suite.state, suite.typeConverter) polls := polls.New(&common, &suite.state, suite.typeConverter)
intReqs := interactionrequests.New(&common, &suite.state, suite.typeConverter)
suite.status = status.New( suite.status = status.New(
&suite.state, &suite.state,
&common, &common,
&polls, &polls,
&intReqs,
suite.federator, suite.federator,
suite.typeConverter, suite.typeConverter,
visFilter, visFilter,

View file

@ -0,0 +1,72 @@
// 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 status
import (
"context"
"errors"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) implicitlyAccept(
ctx context.Context,
requester *gtsmodel.Account,
status *gtsmodel.Status,
) (bool, gtserror.WithCode) {
if status.InReplyToAccountID != requester.ID {
// Status doesn't reply to us,
// we can't accept on behalf
// of someone else.
return false, nil
}
targetPendingApproval := util.PtrOrValue(status.PendingApproval, false)
if !targetPendingApproval {
// Status isn't pending approval,
// nothing to implicitly accept.
return false, nil
}
// Status is pending approval,
// check for an interaction request.
intReq, err := p.state.DB.GetInteractionRequestByInteractionURI(ctx, status.URI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Something's gone wrong.
err := gtserror.Newf("db error getting interaction request for %s: %w", status.URI, err)
return false, gtserror.NewErrorInternalError(err)
}
// No interaction request present
// for this status. Race condition?
if intReq == nil {
return false, nil
}
// Accept the interaction.
if _, errWithCode := p.intReqs.Accept(ctx,
requester, intReq.ID,
); errWithCode != nil {
return false, errWithCode
}
return true, nil
}

View file

@ -800,26 +800,55 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor
}, nil }, nil
} }
// StatusToAPIStatus converts a gts model status into its api // StatusToAPIStatus converts a gts model
// (frontend) representation for serialization on the API. // status into its api (frontend) representation
// for serialization on the API.
// //
// Requesting account can be nil. // Requesting account can be nil.
// //
// Filter context can be the empty string if these statuses are not being filtered. // filterContext can be the empty string
// if these statuses are not being filtered.
// //
// If there is a matching "hide" filter, the returned status will be nil with a ErrHideStatus error; // If there is a matching "hide" filter, the returned
// callers need to handle that case by excluding it from results. // status will be nil with a ErrHideStatus error; callers
// need to handle that case by excluding it from results.
func (c *Converter) StatusToAPIStatus( func (c *Converter) StatusToAPIStatus(
ctx context.Context, ctx context.Context,
s *gtsmodel.Status, status *gtsmodel.Status,
requestingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext, filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter, filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList, mutes *usermute.CompiledUserMuteList,
) (*apimodel.Status, error) {
return c.statusToAPIStatus(
ctx,
status,
requestingAccount,
filterContext,
filters,
mutes,
true,
true,
)
}
// statusToAPIStatus is the package-internal implementation
// of StatusToAPIStatus that lets the caller customize whether
// to placehold unknown attachment types, and/or add a note
// about the status being pending and requiring approval.
func (c *Converter) statusToAPIStatus(
ctx context.Context,
status *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList,
placeholdAttachments bool,
addPendingNote bool,
) (*apimodel.Status, error) { ) (*apimodel.Status, error) {
apiStatus, err := c.statusToFrontend( apiStatus, err := c.statusToFrontend(
ctx, ctx,
s, status,
requestingAccount, // Can be nil. requestingAccount, // Can be nil.
filterContext, // Can be empty. filterContext, // Can be empty.
filters, filters,
@ -830,7 +859,7 @@ func (c *Converter) StatusToAPIStatus(
} }
// Convert author to API model. // Convert author to API model.
acct, err := c.AccountToAPIAccountPublic(ctx, s.Account) acct, err := c.AccountToAPIAccountPublic(ctx, status.Account)
if err != nil { if err != nil {
return nil, gtserror.Newf("error converting status acct: %w", err) return nil, gtserror.Newf("error converting status acct: %w", err)
} }
@ -839,23 +868,43 @@ func (c *Converter) StatusToAPIStatus(
// Convert author of boosted // Convert author of boosted
// status (if set) to API model. // status (if set) to API model.
if apiStatus.Reblog != nil { if apiStatus.Reblog != nil {
boostAcct, err := c.AccountToAPIAccountPublic(ctx, s.BoostOfAccount) boostAcct, err := c.AccountToAPIAccountPublic(ctx, status.BoostOfAccount)
if err != nil { if err != nil {
return nil, gtserror.Newf("error converting boost acct: %w", err) return nil, gtserror.Newf("error converting boost acct: %w", err)
} }
apiStatus.Reblog.Account = boostAcct apiStatus.Reblog.Account = boostAcct
} }
// Normalize status for API by pruning if placeholdAttachments {
// attachments that were not locally // Normalize status for API by pruning attachments
// stored, replacing them with a helpful // that were not able to be locally stored, and replacing
// message + links to remote. // them with a helpful message + links to remote.
var aside string var attachNote string
aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments) attachNote, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)
apiStatus.Content += aside apiStatus.Content += attachNote
if apiStatus.Reblog != nil {
aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments) // Do the same for the reblogged status.
apiStatus.Reblog.Content += aside if apiStatus.Reblog != nil {
attachNote, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments)
apiStatus.Reblog.Content += attachNote
}
}
if addPendingNote {
// If this status is pending approval and
// replies to the requester, add a note
// about how to approve or reject the reply.
pendingApproval := util.PtrOrValue(status.PendingApproval, false)
if pendingApproval &&
requestingAccount != nil &&
requestingAccount.ID == status.InReplyToAccountID {
pendingNote, err := c.pendingReplyNote(ctx, status)
if err != nil {
return nil, gtserror.Newf("error deriving 'pending reply' note: %w", err)
}
apiStatus.Content += pendingNote
}
} }
return apiStatus, nil return apiStatus, nil
@ -1972,7 +2021,20 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
} }
} }
for _, s := range r.Statuses { for _, s := range r.Statuses {
status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil) status, err := c.statusToAPIStatus(
ctx,
s,
requestingAccount,
statusfilter.FilterContextNone,
nil, // No filters.
nil, // No mutes.
true, // Placehold unknown attachments.
// Don't add note about
// pending, it's not
// relevant here.
false,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err) return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
} }
@ -2609,8 +2671,8 @@ func (c *Converter) InteractionReqToAPIInteractionReq(
req.Status, req.Status,
requestingAcct, requestingAcct,
statusfilter.FilterContextNone, statusfilter.FilterContextNone,
nil, nil, // No filters.
nil, nil, // No mutes.
) )
if err != nil { if err != nil {
err := gtserror.Newf("error converting interacted status: %w", err) err := gtserror.Newf("error converting interacted status: %w", err)
@ -2619,13 +2681,20 @@ func (c *Converter) InteractionReqToAPIInteractionReq(
var reply *apimodel.Status var reply *apimodel.Status
if req.InteractionType == gtsmodel.InteractionReply { if req.InteractionType == gtsmodel.InteractionReply {
reply, err = c.StatusToAPIStatus( reply, err = c.statusToAPIStatus(
ctx, ctx,
req.Reply, req.Status,
requestingAcct, requestingAcct,
statusfilter.FilterContextNone, statusfilter.FilterContextNone,
nil, nil, // No filters.
nil, nil, // No mutes.
true, // Placehold unknown attachments.
// Don't add note about pending;
// requester already knows it's
// pending because they're looking
// at the request right now.
false,
) )
if err != nil { if err != nil {
err := gtserror.Newf("error converting reply: %w", err) err := gtserror.Newf("error converting reply: %w", err)

View file

@ -18,6 +18,7 @@
package typeutils_test package typeutils_test
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"testing" "testing"
@ -1708,6 +1709,130 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction
}`, string(b)) }`, string(b))
} }
func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() {
var (
testStatus = suite.testStatuses["admin_account_status_5"]
requestingAccount = suite.testAccounts["local_account_2"]
)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
requestingAccount,
statusfilter.FilterContextNone,
nil,
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
// We want to see the HTML in
// the status so don't escape it.
out := new(bytes.Buffer)
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(apiStatus); err != nil {
suite.FailNow(err.Error())
}
suite.Equal(`{
"id": "01J5QVB9VC76NPPRQ207GG4DRZ",
"created_at": "2024-02-20T10:41:37.000Z",
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"sensitive": false,
"spoiler_text": "",
"visibility": "unlisted",
"language": null,
"uri": "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
"url": "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><hr><p><i lang=\"en\"> Note from localhost:8080: This reply to your status is pending your approval. You can accept the reply by liking, replying to, or boosting it. You can also accept or reject the reply at the following link: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR (opens in a new tab)</a>.</i></p>",
"reblog": null,
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"roles": [
{
"id": "admin",
"name": "admin",
"color": ""
}
]
},
"media_attachments": [],
"mentions": [
{
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"url": "http://localhost:8080/@1happyturtle",
"acct": "1happyturtle"
}
],
"tags": [],
"emojis": [],
"card": null,
"poll": null,
"text": "Hi @1happyturtle, can I reply?",
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
}
}
}
`, out.String())
}
func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() { func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"] testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"]
apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment) apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment)

View file

@ -19,6 +19,7 @@ package typeutils
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math" "math"
"net/url" "net/url"
@ -30,6 +31,8 @@ import (
"github.com/k3a/html2text" "github.com/k3a/html2text"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/language" "github.com/superseriousbusiness/gotosocial/internal/language"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -187,6 +190,47 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att
return text.SanitizeToHTML(note.String()), arr return text.SanitizeToHTML(note.String()), arr
} }
func (c *Converter) pendingReplyNote(
ctx context.Context,
s *gtsmodel.Status,
) (string, error) {
intReq, err := c.state.DB.GetInteractionRequestByInteractionURI(ctx, s.URI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Something's gone wrong.
err := gtserror.Newf("db error getting interaction request for %s: %w", s.URI, err)
return "", err
}
// No interaction request present
// for this status. Race condition?
if intReq == nil {
return "", nil
}
var (
proto = config.GetProtocol()
host = config.GetHost()
// Build the settings panel URL at which the user
// can view + approve/reject the interaction request.
//
// Eg., https://example.org/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR
settingsURL = proto + "://" + host + "/settings/user/interaction_requests/" + intReq.ID
)
var note strings.Builder
note.WriteString(`<hr>`)
note.WriteString(`<p><i lang="en"> Note from ` + host + `: `)
note.WriteString(`This reply to your status is pending your approval. You can accept the reply by liking, replying to, or boosting it. You can also accept or reject the reply at the following link: `)
note.WriteString(`<a href="` + settingsURL + `" `)
note.WriteString(`rel="noreferrer noopener" target="_blank">`)
note.WriteString(settingsURL + ` (opens in a new tab)`)
note.WriteString(`</a>.`)
note.WriteString(`</i></p>`)
return text.SanitizeToHTML(note.String()), nil
}
// ContentToContentLanguage tries to // ContentToContentLanguage tries to
// extract a content string and language // extract a content string and language
// tag string from the given intermediary // tag string from the given intermediary

View file

@ -52,10 +52,10 @@ export default function UserRouter() {
<Route path="/emailpassword" component={EmailPassword} /> <Route path="/emailpassword" component={EmailPassword} />
<Route path="/migration" component={UserMigration} /> <Route path="/migration" component={UserMigration} />
<Route path="/export-import" component={ExportImport} /> <Route path="/export-import" component={ExportImport} />
<InteractionRequestsRouter />
<Route><Redirect to="/profile" /></Route> <Route><Redirect to="/profile" /></Route>
</Switch> </Switch>
</ErrorBoundary> </ErrorBoundary>
<InteractionRequestsRouter />
</Router> </Router>
</BaseUrlContext.Provider> </BaseUrlContext.Provider>
); );
@ -73,13 +73,11 @@ function InteractionRequestsRouter() {
return ( return (
<BaseUrlContext.Provider value={absBase}> <BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}> <Router base={thisBase}>
<ErrorBoundary> <Switch>
<Switch> <Route path="/search" component={InteractionRequests} />
<Route path="/search" component={InteractionRequests} /> <Route path="/:reqId" component={InteractionRequestDetail} />
<Route path="/:reqId" component={InteractionRequestDetail} /> <Route><Redirect to="/search"/></Route>
<Route><Redirect to="/search"/></Route> </Switch>
</Switch>
</ErrorBoundary>
</Router> </Router>
</BaseUrlContext.Provider> </BaseUrlContext.Provider>
); );