[feature] Show info for pending replies, allow implicit accept of pending replies (#3322)

* [feature] Allow implicit accept of pending replies

* update wording
This commit is contained in:
tobi 2024-09-23 14:42:19 +02:00 committed by GitHub
parent 2f13b72e2e
commit 1ce854358d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1318 additions and 377 deletions

View file

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

View file

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

View file

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

View file

@ -18,20 +18,18 @@
package statuses_test package statuses_test
import ( import (
"encoding/json" "context"
"fmt"
"io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -40,90 +38,260 @@ type StatusFaveTestSuite struct {
StatusStandardTestSuite StatusStandardTestSuite
} }
// fave a status func (suite *StatusFaveTestSuite) postStatusFave(
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, 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")
// Populate target status ID.
ctx.Params = gin.Params{
gin.Param{
Key: apiutil.IDKey,
Value: targetStatusID,
},
}
// Trigger handler.
suite.statusModule.StatusFavePOSTHandler(ctx)
return suite.parseStatusResponse(recorder)
}
// Fave a status we haven't faved yet.
func (suite *StatusFaveTestSuite) TestPostFave() { func (suite *StatusFaveTestSuite) TestPostFave() {
t := suite.testTokens["local_account_1"] var (
oauthToken := oauth.DBTokenToToken(t) 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"]
)
targetStatus := suite.testStatuses["admin_account_status_2"] out, recorder := suite.postStatusFave(
targetStatus.ID,
app,
token,
user,
account,
)
// setup // We should have OK from
recorder := httptest.NewRecorder() // our call to the function.
ctx, _ := testrig.CreateGinTestContext(recorder, nil) suite.Equal(http.StatusOK, recorder.Code)
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.Request.Header.Set("accept", "application/json")
// normally the router would populate these params from the path values, // Target status should now
// but because we're calling the function directly, we need to set them manually. // be "favourited" by us.
ctx.Params = gin.Params{ suite.Equal(`{
gin.Param{ "account": "yeah this is my account, what about it punk",
Key: statuses.IDKey, "application": {
Value: targetStatus.ID, "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)
} }
suite.statusModule.StatusFavePOSTHandler(ctx) // Try to fave a status
// that's not faveable by us.
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
statusReply := &apimodel.Status{}
err = json.Unmarshal(b, statusReply)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
assert.True(suite.T(), statusReply.Sensitive)
assert.Equal(suite.T(), apimodel.VisibilityPublic, statusReply.Visibility)
assert.True(suite.T(), statusReply.Favourited)
assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
}
// try to fave a status that's not faveable
func (suite *StatusFaveTestSuite) TestPostUnfaveable() { func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
t := suite.testTokens["admin_account"] var (
oauthToken := oauth.DBTokenToToken(t) targetStatus = suite.testStatuses["local_account_1_status_3"]
app = suite.testApplications["application_1"]
token = suite.testTokens["admin_account"]
user = suite.testUsers["admin_account"]
account = suite.testAccounts["admin_account"]
)
targetStatus := suite.testStatuses["local_account_1_status_3"] // this one is unlikeable out, recorder := suite.postStatusFave(
targetStatus.ID,
app,
token,
user,
account,
)
// setup // We should have 403 from
recorder := httptest.NewRecorder() // our call to the function.
ctx, _ := testrig.CreateGinTestContext(recorder, nil) suite.Equal(http.StatusForbidden, recorder.Code)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["admin_account"])
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["admin_account"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
ctx.Request.Header.Set("accept", "application/json")
// normally the router would populate these params from the path values, // We should get a helpful error.
// but because we're calling the function directly, we need to set them manually. suite.Equal(`{
ctx.Params = gin.Params{ "error": "Forbidden: you do not have permission to fave this status"
gin.Param{ }`, out)
Key: statuses.IDKey,
Value: targetStatus.ID,
},
} }
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 out, recorder := suite.postStatusFave(
suite.EqualValues(http.StatusForbidden, recorder.Code) targetStatus.ID,
app,
token,
user,
account,
)
result := recorder.Result() // We should have OK from
defer result.Body.Close() // our call to the function.
b, err := ioutil.ReadAll(result.Body) suite.Equal(http.StatusOK, recorder.Code)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), `{"error":"Forbidden: you do not have permission to fave this status"}`, string(b)) // 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) { func TestStatusFaveTestSuite(t *testing.T) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,72 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package status
import (
"context"
"errors"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) implicitlyAccept(
ctx context.Context,
requester *gtsmodel.Account,
status *gtsmodel.Status,
) (bool, gtserror.WithCode) {
if status.InReplyToAccountID != requester.ID {
// Status doesn't reply to us,
// we can't accept on behalf
// of someone else.
return false, nil
}
targetPendingApproval := util.PtrOrValue(status.PendingApproval, false)
if !targetPendingApproval {
// Status isn't pending approval,
// nothing to implicitly accept.
return false, nil
}
// Status is pending approval,
// check for an interaction request.
intReq, err := p.state.DB.GetInteractionRequestByInteractionURI(ctx, status.URI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Something's gone wrong.
err := gtserror.Newf("db error getting interaction request for %s: %w", status.URI, err)
return false, gtserror.NewErrorInternalError(err)
}
// No interaction request present
// for this status. Race condition?
if intReq == nil {
return false, nil
}
// Accept the interaction.
if _, errWithCode := p.intReqs.Accept(ctx,
requester, intReq.ID,
); errWithCode != nil {
return false, errWithCode
}
return true, nil
}

View file

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

View file

@ -18,6 +18,7 @@
package typeutils_test package typeutils_test
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"testing" "testing"
@ -1708,6 +1709,130 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction
}`, string(b)) }`, string(b))
} }
func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() {
var (
testStatus = suite.testStatuses["admin_account_status_5"]
requestingAccount = suite.testAccounts["local_account_2"]
)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
requestingAccount,
statusfilter.FilterContextNone,
nil,
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
// We want to see the HTML in
// the status so don't escape it.
out := new(bytes.Buffer)
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(apiStatus); err != nil {
suite.FailNow(err.Error())
}
suite.Equal(`{
"id": "01J5QVB9VC76NPPRQ207GG4DRZ",
"created_at": "2024-02-20T10:41:37.000Z",
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"sensitive": false,
"spoiler_text": "",
"visibility": "unlisted",
"language": null,
"uri": "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
"url": "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><hr><p><i lang=\"en\"> Note from localhost:8080: This reply 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() { func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"] testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"]
apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment) apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment)

View file

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

View file

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