diff options
| -rw-r--r-- | internal/api/client/statuses/status_test.go | 114 | ||||
| -rw-r--r-- | internal/api/client/statuses/statusboost_test.go | 751 | ||||
| -rw-r--r-- | internal/api/client/statuses/statuscreate_test.go | 92 | ||||
| -rw-r--r-- | internal/api/client/statuses/statusfave_test.go | 300 | ||||
| -rw-r--r-- | internal/processing/processor.go | 2 | ||||
| -rw-r--r-- | internal/processing/status/boost.go | 18 | ||||
| -rw-r--r-- | internal/processing/status/create.go | 17 | ||||
| -rw-r--r-- | internal/processing/status/fave.go | 22 | ||||
| -rw-r--r-- | internal/processing/status/status.go | 6 | ||||
| -rw-r--r-- | internal/processing/status/status_test.go | 3 | ||||
| -rw-r--r-- | internal/processing/status/util.go | 72 | ||||
| -rw-r--r-- | internal/typeutils/internaltofrontend.go | 121 | ||||
| -rw-r--r-- | internal/typeutils/internaltofrontend_test.go | 125 | ||||
| -rw-r--r-- | internal/typeutils/util.go | 44 | ||||
| -rw-r--r-- | web/source/settings/views/user/router.tsx | 14 | 
15 files changed, 1321 insertions, 380 deletions
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) - -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	suite.NoError(err) - -	statusReply := &apimodel.Status{} -	err = json.Unmarshal(b, statusReply) -	suite.NoError(err) - -	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) +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"] +	) + +	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": 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) - -	testStatus := suite.testStatuses["local_account_1_status_5"] -	testAccount := suite.testAccounts["local_account_1"] -	testUser := suite.testUsers["local_account_1"] - -	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") - -	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) +	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"] +	) + +	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": [ +        "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) - -	targetStatus := suite.testStatuses["local_account_2_status_4"] - -	// 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 +	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"] +	) + +	out, recorder := suite.postStatusBoost( +		targetStatus.ID, +		app, +		token, +		user, +		account, +	) + +	// 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"] +	) + +	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) +} -	// check response -	suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible +// Boost a status that's pending approval by us. +func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() { +	var ( +		targetStatus = suite.testStatuses["admin_account_status_5"] +		app          = suite.testApplications["application_1"] +		token        = suite.testTokens["local_account_2"] +		user         = suite.testUsers["local_account_2"] +		account      = suite.testAccounts["local_account_2"] +	) + +	out, recorder := suite.postStatusBoost( +		targetStatus.ID, +		app, +		token, +		user, +		account, +	) + +	// We should have OK from +	// our call to the function. +	suite.Equal(http.StatusOK, recorder.Code) + +	// Target status should now +	// be "reblogged" by us. +	suite.Equal(`{ +  "account": "yeah this is my account, what about it punk", +  "application": { +    "name": "really cool gts application", +    "website": "https://reallycool.app" +  }, +  "bookmarked": false, +  "card": null, +  "content": "", +  "created_at": "right the hell just now babyee", +  "emojis": [], +  "favourited": false, +  "favourites_count": 0, +  "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", +  "in_reply_to_account_id": null, +  "in_reply_to_id": null, +  "interaction_policy": { +    "can_favourite": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    }, +    "can_reblog": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    }, +    "can_reply": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    } +  }, +  "language": null, +  "media_attachments": [], +  "mentions": [], +  "muted": false, +  "pinned": false, +  "poll": null, +  "reblog": { +    "account": "yeah this is my account, what about it punk", +    "application": { +      "name": "superseriousbusiness", +      "website": "https://superserious.business" +    }, +    "bookmarked": false, +    "card": null, +    "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", +    "created_at": "right the hell just now babyee", +    "emojis": [], +    "favourited": false, +    "favourites_count": 0, +    "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", +    "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", +    "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", +    "interaction_policy": { +      "can_favourite": { +        "always": [ +          "public", +          "me" +        ], +        "with_approval": [] +      }, +      "can_reblog": { +        "always": [ +          "public", +          "me" +        ], +        "with_approval": [] +      }, +      "can_reply": { +        "always": [ +          "public", +          "me" +        ], +        "with_approval": [] +      } +    }, +    "language": null, +    "media_attachments": [], +    "mentions": [ +      { +        "acct": "1happyturtle", +        "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", +        "url": "http://localhost:8080/@1happyturtle", +        "username": "1happyturtle" +      } +    ], +    "muted": false, +    "pinned": false, +    "poll": null, +    "reblog": null, +    "reblogged": true, +    "reblogs_count": 1, +    "replies_count": 0, +    "sensitive": false, +    "spoiler_text": "", +    "tags": [], +    "text": "Hi @1happyturtle, can I reply?", +    "uri": "http://localhost:8080/some/determinate/url", +    "url": "http://localhost:8080/some/determinate/url", +    "visibility": "unlisted" +  }, +  "reblogged": true, +  "reblogs_count": 0, +  "replies_count": 0, +  "sensitive": false, +  "spoiler_text": "", +  "tags": [], +  "uri": "http://localhost:8080/some/determinate/url", +  "url": "http://localhost:8080/some/determinate/url", +  "visibility": "unlisted" +}`, out) + +	// Target status should no +	// longer be pending approval. +	dbStatus, err := suite.state.DB.GetStatusByID( +		context.Background(), +		targetStatus.ID, +	) +	if err != nil { +		suite.FailNow(err.Error()) +	} +	suite.False(*dbStatus.PendingApproval) + +	// There should be an Accept +	// stored for the target status. +	intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI( +		context.Background(), targetStatus.URI, +	) +	if err != nil { +		suite.FailNow(err.Error()) +	} +	suite.NotZero(intReq.AcceptedAt) +	suite.NotEmpty(intReq.URI)  }  func TestStatusBoostTestSuite(t *testing.T) { 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) +	return suite.parseStatusResponse(recorder) +} -	// check response -	suite.EqualValues(http.StatusOK, recorder.Code) +// 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"] +	) -	result := recorder.Result() -	defer result.Body.Close() -	b, err := ioutil.ReadAll(result.Body) -	assert.NoError(suite.T(), err) +	out, recorder := suite.postStatusFave( +		targetStatus.ID, +		app, +		token, +		user, +		account, +	) -	statusReply := &apimodel.Status{} -	err = json.Unmarshal(b, statusReply) -	assert.NoError(suite.T(), err) +	// We should have OK from +	// our call to the function. +	suite.Equal(http.StatusOK, recorder.Code) -	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) +	// 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 +// 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) +} -	suite.statusModule.StatusFavePOSTHandler(ctx) +// 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"] +	) -	// check response -	suite.EqualValues(http.StatusForbidden, recorder.Code) +	out, recorder := suite.postStatusFave( +		targetStatus.ID, +		app, +		token, +		user, +		account, +	) -	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)) +	// We should have OK from +	// our call to the function. +	suite.Equal(http.StatusOK, recorder.Code) + +	// Target status should now +	// be "favourited" by us. +	suite.Equal(`{ +  "account": "yeah this is my account, what about it punk", +  "application": { +    "name": "superseriousbusiness", +    "website": "https://superserious.business" +  }, +  "bookmarked": false, +  "card": null, +  "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", +  "created_at": "right the hell just now babyee", +  "emojis": [], +  "favourited": true, +  "favourites_count": 1, +  "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", +  "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", +  "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", +  "interaction_policy": { +    "can_favourite": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    }, +    "can_reblog": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    }, +    "can_reply": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    } +  }, +  "language": null, +  "media_attachments": [], +  "mentions": [ +    { +      "acct": "1happyturtle", +      "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", +      "url": "http://localhost:8080/@1happyturtle", +      "username": "1happyturtle" +    } +  ], +  "muted": false, +  "pinned": false, +  "poll": null, +  "reblog": null, +  "reblogged": false, +  "reblogs_count": 0, +  "replies_count": 0, +  "sensitive": false, +  "spoiler_text": "", +  "tags": [], +  "text": "Hi @1happyturtle, can I reply?", +  "uri": "http://localhost:8080/some/determinate/url", +  "url": "http://localhost:8080/some/determinate/url", +  "visibility": "unlisted" +}`, out) + +	// Target status should no +	// longer be pending approval. +	dbStatus, err := suite.state.DB.GetStatusByID( +		context.Background(), +		targetStatus.ID, +	) +	if err != nil { +		suite.FailNow(err.Error()) +	} +	suite.False(*dbStatus.PendingApproval) + +	// 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 <http://www.gnu.org/licenses/>. + +package status + +import ( +	"context" +	"errors" + +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *Processor) implicitlyAccept( +	ctx context.Context, +	requester *gtsmodel.Account, +	status *gtsmodel.Status, +) (bool, gtserror.WithCode) { +	if status.InReplyToAccountID != requester.ID { +		// Status doesn't reply to us, +		// we can't accept on behalf +		// of someone else. +		return false, nil +	} + +	targetPendingApproval := util.PtrOrValue(status.PendingApproval, false) +	if !targetPendingApproval { +		// Status isn't pending approval, +		// nothing to implicitly accept. +		return false, nil +	} + +	// Status is pending approval, +	// check for an interaction request. +	intReq, err := p.state.DB.GetInteractionRequestByInteractionURI(ctx, status.URI) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		// Something's gone wrong. +		err := gtserror.Newf("db error getting interaction request for %s: %w", status.URI, err) +		return false, gtserror.NewErrorInternalError(err) +	} + +	// No interaction request present +	// for this status. Race condition? +	if intReq == nil { +		return false, nil +	} + +	// Accept the interaction. +	if _, errWithCode := p.intReqs.Accept(ctx, +		requester, intReq.ID, +	); errWithCode != nil { +		return false, errWithCode +	} + +	return true, nil +} 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": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><hr><p><i lang=\"en\">âšī¸ Note from localhost:8080: This reply 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: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR</a>.</i></p>", +  "reblog": null, +  "application": { +    "name": "superseriousbusiness", +    "website": "https://superserious.business" +  }, +  "account": { +    "id": "01F8MH17FWEB39HZJ76B6VXSKF", +    "username": "admin", +    "acct": "admin", +    "display_name": "", +    "locked": false, +    "discoverable": true, +    "bot": false, +    "created_at": "2022-05-17T13:10:59.000Z", +    "note": "", +    "url": "http://localhost:8080/@admin", +    "avatar": "", +    "avatar_static": "", +    "header": "http://localhost:8080/assets/default_header.webp", +    "header_static": "http://localhost:8080/assets/default_header.webp", +    "followers_count": 1, +    "following_count": 1, +    "statuses_count": 4, +    "last_status_at": "2021-10-20T10:41:37.000Z", +    "emojis": [], +    "fields": [], +    "enable_rss": true, +    "roles": [ +      { +        "id": "admin", +        "name": "admin", +        "color": "" +      } +    ] +  }, +  "media_attachments": [], +  "mentions": [ +    { +      "id": "01F8MH5NBDF2MV7CTC4Q5128HF", +      "username": "1happyturtle", +      "url": "http://localhost:8080/@1happyturtle", +      "acct": "1happyturtle" +    } +  ], +  "tags": [], +  "emojis": [], +  "card": null, +  "poll": null, +  "text": "Hi @1happyturtle, can I reply?", +  "interaction_policy": { +    "can_favourite": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    }, +    "can_reply": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    }, +    "can_reblog": { +      "always": [ +        "public", +        "me" +      ], +      "with_approval": [] +    } +  } +} +`, out.String()) +} +  func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {  	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 @@ 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(`<hr>`) +	note.WriteString(`<p><i lang="en">âšī¸ 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(`<a href="` + settingsURL + `" `) +	note.WriteString(`rel="noreferrer noopener" target="_blank">`) +	note.WriteString(settingsURL) +	note.WriteString(`</a>.`) +	note.WriteString(`</i></p>`) + +	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() {  						<Route path="/emailpassword" component={EmailPassword} />  						<Route path="/migration" component={UserMigration} />  						<Route path="/export-import" component={ExportImport} /> +						<InteractionRequestsRouter />  						<Route><Redirect to="/profile" /></Route>  					</Switch>  				</ErrorBoundary> -				<InteractionRequestsRouter />  			</Router>  		</BaseUrlContext.Provider>  	); @@ -73,13 +73,11 @@ function InteractionRequestsRouter() {  	return (  		<BaseUrlContext.Provider value={absBase}>  			<Router base={thisBase}> -				<ErrorBoundary> -					<Switch> -						<Route path="/search" component={InteractionRequests} /> -						<Route path="/:reqId" component={InteractionRequestDetail} /> -						<Route><Redirect to="/search"/></Route> -					</Switch> -				</ErrorBoundary> +				<Switch> +					<Route path="/search" component={InteractionRequests} /> +					<Route path="/:reqId" component={InteractionRequestDetail} /> +					<Route><Redirect to="/search"/></Route> +				</Switch>  			</Router>  		</BaseUrlContext.Provider>  	);  | 
