This commit is contained in:
tobi 2024-09-20 15:05:10 +00:00 committed by GitHub
commit d811be24b7
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
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()

View file

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

View file

@ -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)

View file

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

View file

@ -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.

View file

@ -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)
}

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)
}

View file

@ -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)
}

View file

@ -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,
}
}

View file

@ -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,

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
}
// 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)

View file

@ -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 to your status is pending your approval. You can accept the reply by liking, replying to, or boosting it. You can also accept or reject the reply at the following link: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR (opens in a new tab)</a>.</i></p>",
"reblog": null,
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"roles": [
{
"id": "admin",
"name": "admin",
"color": ""
}
]
},
"media_attachments": [],
"mentions": [
{
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"url": "http://localhost:8080/@1happyturtle",
"acct": "1happyturtle"
}
],
"tags": [],
"emojis": [],
"card": null,
"poll": null,
"text": "Hi @1happyturtle, can I reply?",
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
}
}
}
`, out.String())
}
func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"]
apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment)

View file

@ -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 to your status is pending your approval. You can accept the reply by liking, replying to, or boosting it. You can also accept or reject the reply at the following link: `)
note.WriteString(`<a href="` + settingsURL + `" `)
note.WriteString(`rel="noreferrer noopener" target="_blank">`)
note.WriteString(settingsURL + ` (opens in a new tab)`)
note.WriteString(`</a>.`)
note.WriteString(`</i></p>`)
return text.SanitizeToHTML(note.String()), nil
}
// ContentToContentLanguage tries to
// extract a content string and language
// tag string from the given intermediary

View file

@ -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>
);