From 1ce854358def5f04b7c3b73418ab56bb58512634 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:42:19 +0200 Subject: [PATCH] [feature] Show info for pending replies, allow implicit accept of pending replies (#3322) * [feature] Allow implicit accept of pending replies * update wording --- internal/api/client/statuses/status_test.go | 114 +++ .../api/client/statuses/statusboost_test.go | 735 +++++++++++++----- .../api/client/statuses/statuscreate_test.go | 92 +-- .../api/client/statuses/statusfave_test.go | 310 ++++++-- internal/processing/processor.go | 2 +- internal/processing/status/boost.go | 18 + internal/processing/status/create.go | 17 + internal/processing/status/fave.go | 22 +- internal/processing/status/status.go | 6 +- internal/processing/status/status_test.go | 3 + internal/processing/status/util.go | 72 ++ internal/typeutils/internaltofrontend.go | 121 ++- internal/typeutils/internaltofrontend_test.go | 125 +++ internal/typeutils/util.go | 44 ++ web/source/settings/views/user/router.tsx | 14 +- 15 files changed, 1318 insertions(+), 377 deletions(-) create mode 100644 internal/processing/status/util.go 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 @@ "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 @@ import ( "context" - "encoding/json" - "fmt" - "io/ioutil" "net/http" "net/http/httptest" "strings" @@ -28,7 +25,7 @@ "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 @@ 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 @@ "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 @@ "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 @@ "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 @@ "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..dbb6d6a5d 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 is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR.

", + "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..1747dbdcd 100644 --- a/internal/typeutils/util.go +++ b/internal/typeutils/util.go @@ -19,6 +19,7 @@ import ( "context" + "errors" "fmt" "math" "net/url" @@ -30,6 +31,8 @@ "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 is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: `) + note.WriteString(``) + note.WriteString(settingsURL) + 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 ( - - - - - - - + + + + + );