diff --git a/internal/api/client/statuses/status_test.go b/internal/api/client/statuses/status_test.go index a979f0c00..1a92276a1 100644 --- a/internal/api/client/statuses/status_test.go +++ b/internal/api/client/statuses/status_test.go @@ -18,6 +18,12 @@ package statuses_test import ( + "bytes" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -25,6 +31,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" @@ -59,6 +66,113 @@ type StatusStandardTestSuite struct { 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() { suite.testTokens = testrig.NewTestTokens() suite.testClients = testrig.NewTestClients() diff --git a/internal/api/client/statuses/statusboost_test.go b/internal/api/client/statuses/statusboost_test.go index f6f589a5c..8642ba7aa 100644 --- a/internal/api/client/statuses/statusboost_test.go +++ b/internal/api/client/statuses/statusboost_test.go @@ -17,9 +17,6 @@ package statuses_test import ( "context" - "encoding/json" - "fmt" - "io/ioutil" "net/http" "net/http/httptest" "strings" @@ -28,7 +25,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/suite" "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/oauth" "github.com/superseriousbusiness/gotosocial/testrig" @@ -38,212 +35,596 @@ type StatusBoostTestSuite struct { StatusStandardTestSuite } -func (suite *StatusBoostTestSuite) TestPostBoost() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["admin_account_status_1"] - - // setup +func (suite *StatusBoostTestSuite) postStatusBoost( + targetStatusID string, + app *gtsmodel.Application, + token *gtsmodel.Token, + user *gtsmodel.User, + account *gtsmodel.Account, +) (string, *httptest.ResponseRecorder) { 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_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.Set(oauth.SessionAuthorizedApplication, app) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token)) + ctx.Set(oauth.SessionAuthorizedUser, user) + ctx.Set(oauth.SessionAuthorizedAccount, account) + + 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") - // 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. + // Populate target status ID. ctx.Params = gin.Params{ gin.Param{ - Key: statuses.IDKey, - Value: targetStatus.ID, + Key: apiutil.IDKey, + Value: targetStatusID, }, } + // Trigger handler. suite.statusModule.StatusBoostPOSTHandler(ctx) + return suite.parseStatusResponse(recorder) +} - // check response - suite.EqualValues(http.StatusOK, recorder.Code) +func (suite *StatusBoostTestSuite) TestPostBoost() { + 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() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) + out, recorder := suite.postStatusBoost( + targetStatus.ID, + app, + token, + user, + account, + ) - statusReply := &apimodel.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) + // We should have OK from + // our call to the function. + suite.Equal(http.StatusOK, recorder.Code) - suite.False(statusReply.Sensitive) - suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility) - - suite.Empty(statusReply.SpoilerText) - suite.Empty(statusReply.Content) - suite.Equal("the_mighty_zork", statusReply.Account.Username) - suite.Len(statusReply.MediaAttachments, 0) - suite.Len(statusReply.Mentions, 0) - suite.Len(statusReply.Emojis, 0) - suite.Len(statusReply.Tags, 0) - - suite.NotNil(statusReply.Application) - suite.Equal("really cool gts application", statusReply.Application.Name) - - suite.NotNil(statusReply.Reblog) - suite.Equal(1, statusReply.Reblog.ReblogsCount) - suite.Equal(1, statusReply.Reblog.FavouritesCount) - suite.Equal(targetStatus.Content, statusReply.Reblog.Content) - suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText) - suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID) - suite.Len(statusReply.Reblog.MediaAttachments, 1) - suite.Len(statusReply.Reblog.Tags, 1) - suite.Len(statusReply.Reblog.Emojis, 1) - suite.True(statusReply.Reblogged) - suite.True(statusReply.Reblog.Reblogged) - suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name) + // 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": true, + "card": null, + "content": "", + "created_at": "right the hell just now babyee", + "emojis": [], + "favourited": true, + "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": 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() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) + var ( + 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"] - testAccount := suite.testAccounts["local_account_1"] - testUser := suite.testUsers["local_account_1"] + out, recorder := suite.postStatusBoost( + targetStatus.ID, + app, + token, + user, + account, + ) - 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, 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") + // We should have OK from + // our call to the function. + suite.Equal(http.StatusOK, recorder.Code) - ctx.Params = gin.Params{ - gin.Param{ - Key: statuses.IDKey, - Value: testStatus.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) - suite.NoError(err) - - responseStatus := &apimodel.Status{} - err = json.Unmarshal(b, responseStatus) - suite.NoError(err) - - suite.False(responseStatus.Sensitive) - suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility) - - suite.Empty(responseStatus.SpoilerText) - suite.Empty(responseStatus.Content) - suite.Equal("the_mighty_zork", responseStatus.Account.Username) - suite.Len(responseStatus.MediaAttachments, 0) - suite.Len(responseStatus.Mentions, 0) - suite.Len(responseStatus.Emojis, 0) - suite.Len(responseStatus.Tags, 0) - - suite.NotNil(responseStatus.Application) - suite.Equal("really cool gts application", responseStatus.Application.Name) - - suite.NotNil(responseStatus.Reblog) - suite.Equal(1, responseStatus.Reblog.ReblogsCount) - suite.Equal(0, responseStatus.Reblog.FavouritesCount) - suite.Equal(testStatus.Content, responseStatus.Reblog.Content) - suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText) - suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID) - suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility) - suite.Empty(responseStatus.Reblog.MediaAttachments) - suite.Empty(responseStatus.Reblog.Tags) - suite.Empty(responseStatus.Reblog.Emojis) - suite.True(responseStatus.Reblogged) - suite.True(responseStatus.Reblog.Reblogged) - suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name) + // 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": [ + "author", + "followers", + "mentioned", + "me" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "author", + "me" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "author", + "followers", + "mentioned", + "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": "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() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) + var ( + 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 - 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_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 + // We should have 403 from + // our call to the function. suite.Equal(http.StatusForbidden, recorder.Code) - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Equal(`{"error":"Forbidden: you do not have permission to boost this status"}`, string(b)) + // We should have a helpful message. + suite.Equal(`{ + "error": "Forbidden: you do not have permission to boost this status" +}`, out) } -// 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() { - // stop local_account_2 following zork - err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, >smodel.Follow{}) - suite.NoError(err) - - t := suite.testTokens["local_account_2"] - oauthToken := oauth.DBTokenToToken(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, _ := 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, - }, + // Stop local_account_2 following zork. + err := suite.db.DeleteFollowByID( + context.Background(), + suite.testFollows["local_account_2_local_account_1"].ID, + ) + if err != nil { + suite.FailNow(err.Error()) } - 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 - suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible + out, recorder := suite.postStatusBoost( + 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": "

Hi @1happyturtle, can I reply?

", + "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) { diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go index d32feb6c7..8598b5ef0 100644 --- a/internal/api/client/statuses/statuscreate_test.go +++ b/internal/api/client/statuses/statuscreate_test.go @@ -20,18 +20,14 @@ package statuses_test import ( "bytes" "context" - "encoding/json" "fmt" - "io" "net/http" "net/http/httptest" - "strings" "testing" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -81,91 +77,7 @@ func (suite *StatusCreateTestSuite) postStatus( // Trigger handler. suite.statusModule.StatusCreatePOSTHandler(ctx) - - 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 + return suite.parseStatusResponse(recorder) } // Post a new status with some custom visibility settings @@ -447,7 +359,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusMessedUpIntPolicy() { suite.Equal(http.StatusBadRequest, recorder.Code) // We should have a helpful error - // message telling us how we screwed up. + // message telling us how we screwed up. suite.Equal(`{ "error": "Bad Request: error converting followers_only.can_reply.always: policyURI public is not feasible for visibility followers_only" }`, out) diff --git a/internal/api/client/statuses/statusfave_test.go b/internal/api/client/statuses/statusfave_test.go index d1042b10e..fdc8741c7 100644 --- a/internal/api/client/statuses/statusfave_test.go +++ b/internal/api/client/statuses/statusfave_test.go @@ -18,20 +18,18 @@ package statuses_test import ( - "encoding/json" - "fmt" - "io/ioutil" + "context" "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/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/testrig" ) @@ -40,90 +38,260 @@ type StatusFaveTestSuite struct { StatusStandardTestSuite } -// fave a status -func (suite *StatusFaveTestSuite) TestPostFave() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["admin_account_status_2"] - - // setup +func (suite *StatusFaveTestSuite) postStatusFave( + targetStatusID string, + app *gtsmodel.Application, + token *gtsmodel.Token, + user *gtsmodel.User, + account *gtsmodel.Account, +) (string, *httptest.ResponseRecorder) { 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_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.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Set(oauth.SessionAuthorizedApplication, app) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token)) + ctx.Set(oauth.SessionAuthorizedUser, user) + ctx.Set(oauth.SessionAuthorizedAccount, account) + + 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") - // 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. + // Populate target status ID. ctx.Params = gin.Params{ gin.Param{ - Key: statuses.IDKey, - Value: targetStatus.ID, + Key: apiutil.IDKey, + Value: targetStatusID, }, } + // Trigger handler. suite.statusModule.StatusFavePOSTHandler(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 := &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) + return suite.parseStatusResponse(recorder) } -// 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() { - t := suite.testTokens["admin_account"] - oauthToken := oauth.DBTokenToToken(t) + var ( + 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 - 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["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") + // We should have 403 from + // our call to the function. + suite.Equal(http.StatusForbidden, recorder.Code) - // 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, - }, + // We should get a helpful error. + suite.Equal(`{ + "error": "Forbidden: you do not have permission to fave this status" +}`, out) +} + +// 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": "

Hi @1happyturtle, can I reply?

", + "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) - - // check response - suite.EqualValues(http.StatusForbidden, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"Forbidden: you do not have permission to fave this status"}`, string(b)) + // 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 TestStatusFaveTestSuite(t *testing.T) { diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 2ed13d396..ce0f1cfb8 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -223,7 +223,7 @@ func NewProcessor( processor.tags = tags.New(state, converter) processor.timeline = timeline.New(state, converter, visFilter) processor.search = search.New(state, federator, converter, visFilter) - processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc) + processor.status = status.New(state, &common, &processor.polls, &processor.interactionRequests, federator, converter, visFilter, intFilter, parseMentionFunc) processor.user = user.New(state, converter, oauthServer, emailSender) // The advanced migrations processor sequences advanced migrations from all other processors. diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go index 1b6e8bd47..0e09a8e7b 100644 --- a/internal/processing/status/boost.go +++ b/internal/processing/status/boost.go @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // BoostCreate processes the boost/reblog of target @@ -138,6 +139,23 @@ func (p *Processor) BoostCreate( 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) } diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 1513018ae..184a92680 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -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) } diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go index 497c4d465..defc59af0 100644 --- a/internal/processing/status/fave.go +++ b/internal/processing/status/fave.go @@ -31,6 +31,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" ) func (p *Processor) getFaveableStatus( @@ -138,8 +139,6 @@ func (p *Processor) FaveCreate( pendingApproval = false } - status.PendingApproval = &pendingApproval - // Create a new fave, marking it // as pending approval if necessary. faveID := id.NewULID() @@ -157,7 +156,7 @@ func (p *Processor) FaveCreate( } 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) } @@ -170,6 +169,23 @@ func (p *Processor) FaveCreate( 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) } diff --git a/internal/processing/status/status.go b/internal/processing/status/status.go index 7e614cc31..26dfd0d7a 100644 --- a/internal/processing/status/status.go +++ b/internal/processing/status/status.go @@ -23,6 +23,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "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/state" "github.com/superseriousbusiness/gotosocial/internal/text" @@ -42,7 +43,8 @@ type Processor struct { parseMention gtsmodel.ParseMentionFunc // other processors - polls *polls.Processor + polls *polls.Processor + intReqs *interactionrequests.Processor } // New returns a new status processor. @@ -50,6 +52,7 @@ func New( state *state.State, common *common.Processor, polls *polls.Processor, + intReqs *interactionrequests.Processor, federator *federation.Federator, converter *typeutils.Converter, visFilter *visibility.Filter, @@ -66,5 +69,6 @@ func New( formatter: text.NewFormatter(state.DB), parseMention: parseMention, polls: polls, + intReqs: intReqs, } } diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go index f0b22b2c1..b3c446d14 100644 --- a/internal/processing/status/status_test.go +++ b/internal/processing/status/status_test.go @@ -27,6 +27,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" "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/status" "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) polls := polls.New(&common, &suite.state, suite.typeConverter) + intReqs := interactionrequests.New(&common, &suite.state, suite.typeConverter) suite.status = status.New( &suite.state, &common, &polls, + &intReqs, suite.federator, suite.typeConverter, visFilter, diff --git a/internal/processing/status/util.go b/internal/processing/status/util.go new file mode 100644 index 000000000..99cff7c56 --- /dev/null +++ b/internal/processing/status/util.go @@ -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 . + +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 +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index fe49766fa..f36175eab 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -800,26 +800,55 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor }, nil } -// StatusToAPIStatus converts a gts model status into its api -// (frontend) representation for serialization on the API. +// StatusToAPIStatus converts a gts model +// status into its api (frontend) representation +// for serialization on the API. // // 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; -// callers need to handle that case by excluding it from results. +// If there is a matching "hide" filter, the returned +// status will be nil with a ErrHideStatus error; callers +// need to handle that case by excluding it from results. func (c *Converter) StatusToAPIStatus( ctx context.Context, - s *gtsmodel.Status, + status *gtsmodel.Status, requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, 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) { apiStatus, err := c.statusToFrontend( ctx, - s, + status, requestingAccount, // Can be nil. filterContext, // Can be empty. filters, @@ -830,7 +859,7 @@ func (c *Converter) StatusToAPIStatus( } // Convert author to API model. - acct, err := c.AccountToAPIAccountPublic(ctx, s.Account) + acct, err := c.AccountToAPIAccountPublic(ctx, status.Account) if err != nil { return nil, gtserror.Newf("error converting status acct: %w", err) } @@ -839,23 +868,43 @@ func (c *Converter) StatusToAPIStatus( // Convert author of boosted // status (if set) to API model. if apiStatus.Reblog != nil { - boostAcct, err := c.AccountToAPIAccountPublic(ctx, s.BoostOfAccount) + boostAcct, err := c.AccountToAPIAccountPublic(ctx, status.BoostOfAccount) if err != nil { return nil, gtserror.Newf("error converting boost acct: %w", err) } apiStatus.Reblog.Account = boostAcct } - // Normalize status for API by pruning - // attachments that were not locally - // stored, replacing them with a helpful - // message + links to remote. - var aside string - aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments) - apiStatus.Content += aside - if apiStatus.Reblog != nil { - aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments) - apiStatus.Reblog.Content += aside + if placeholdAttachments { + // Normalize status for API by pruning attachments + // that were not able to be locally stored, and replacing + // them with a helpful message + links to remote. + var attachNote string + attachNote, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments) + apiStatus.Content += attachNote + + // Do the same for the reblogged status. + 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 @@ -1972,7 +2021,20 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo } } 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 { 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, requestingAcct, statusfilter.FilterContextNone, - nil, - nil, + nil, // No filters. + nil, // No mutes. ) if err != nil { err := gtserror.Newf("error converting interacted status: %w", err) @@ -2619,13 +2681,20 @@ func (c *Converter) InteractionReqToAPIInteractionReq( var reply *apimodel.Status if req.InteractionType == gtsmodel.InteractionReply { - reply, err = c.StatusToAPIStatus( + reply, err = c.statusToAPIStatus( ctx, - req.Reply, + req.Status, requestingAcct, statusfilter.FilterContextNone, - nil, - nil, + nil, // No filters. + 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 { err := gtserror.Newf("error converting reply: %w", err) diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index a44afe67e..5c9c567f5 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -18,6 +18,7 @@ package typeutils_test import ( + "bytes" "context" "encoding/json" "testing" @@ -1708,6 +1709,130 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction }`, 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": "

Hi @1happyturtle, can I reply?


â„šī¸ 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: http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR (opens in a new tab).

", + "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() { testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"] apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment) diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go index 3a867ba35..b7b8f10f1 100644 --- a/internal/typeutils/util.go +++ b/internal/typeutils/util.go @@ -19,6 +19,7 @@ package typeutils import ( "context" + "errors" "fmt" "math" "net/url" @@ -30,6 +31,8 @@ import ( "github.com/k3a/html2text" 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/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/language" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -187,6 +190,47 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att 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(`
`) + note.WriteString(`

â„šī¸ 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(``) + note.WriteString(settingsURL + ` (opens in a new tab)`) + note.WriteString(`.`) + note.WriteString(`

`) + + return text.SanitizeToHTML(note.String()), nil +} + // ContentToContentLanguage tries to // extract a content string and language // tag string from the given intermediary diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx index 86bcf4243..091dd40ae 100644 --- a/web/source/settings/views/user/router.tsx +++ b/web/source/settings/views/user/router.tsx @@ -52,10 +52,10 @@ export default function UserRouter() { + - ); @@ -73,13 +73,11 @@ function InteractionRequestsRouter() { return ( - - - - - - - + + + + + );