mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-12-03 04:08:06 -06:00
Merge branch 'main' into profile-boosts
This commit is contained in:
commit
90b773ae2a
82 changed files with 2313 additions and 954 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ package statuses_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -28,7 +25,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
|
|
@ -38,212 +35,596 @@ type StatusBoostTestSuite struct {
|
|||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *StatusBoostTestSuite) TestPostBoost() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
// setup
|
||||
func (suite *StatusBoostTestSuite) postStatusBoost(
|
||||
targetStatusID string,
|
||||
app *gtsmodel.Application,
|
||||
token *gtsmodel.Token,
|
||||
user *gtsmodel.User,
|
||||
account *gtsmodel.Account,
|
||||
) (string, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, app)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
|
||||
ctx.Set(oauth.SessionAuthorizedUser, user)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, account)
|
||||
|
||||
const pathBase = "http://localhost:8080/api" + statuses.ReblogPath
|
||||
path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
// Populate target status ID.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
Key: apiutil.IDKey,
|
||||
Value: targetStatusID,
|
||||
},
|
||||
}
|
||||
|
||||
// Trigger handler.
|
||||
suite.statusModule.StatusBoostPOSTHandler(ctx)
|
||||
return suite.parseStatusResponse(recorder)
|
||||
}
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
func (suite *StatusBoostTestSuite) TestPostBoost() {
|
||||
var (
|
||||
targetStatus = suite.testStatuses["admin_account_status_1"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_1"]
|
||||
user = suite.testUsers["local_account_1"]
|
||||
account = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
statusReply := &apimodel.Status{}
|
||||
err = json.Unmarshal(b, statusReply)
|
||||
suite.NoError(err)
|
||||
// We should have OK from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusOK, recorder.Code)
|
||||
|
||||
suite.False(statusReply.Sensitive)
|
||||
suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility)
|
||||
|
||||
suite.Empty(statusReply.SpoilerText)
|
||||
suite.Empty(statusReply.Content)
|
||||
suite.Equal("the_mighty_zork", statusReply.Account.Username)
|
||||
suite.Len(statusReply.MediaAttachments, 0)
|
||||
suite.Len(statusReply.Mentions, 0)
|
||||
suite.Len(statusReply.Emojis, 0)
|
||||
suite.Len(statusReply.Tags, 0)
|
||||
|
||||
suite.NotNil(statusReply.Application)
|
||||
suite.Equal("really cool gts application", statusReply.Application.Name)
|
||||
|
||||
suite.NotNil(statusReply.Reblog)
|
||||
suite.Equal(1, statusReply.Reblog.ReblogsCount)
|
||||
suite.Equal(1, statusReply.Reblog.FavouritesCount)
|
||||
suite.Equal(targetStatus.Content, statusReply.Reblog.Content)
|
||||
suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText)
|
||||
suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID)
|
||||
suite.Len(statusReply.Reblog.MediaAttachments, 1)
|
||||
suite.Len(statusReply.Reblog.Tags, 1)
|
||||
suite.Len(statusReply.Reblog.Emojis, 1)
|
||||
suite.True(statusReply.Reblogged)
|
||||
suite.True(statusReply.Reblog.Reblogged)
|
||||
suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name)
|
||||
// Target status should now
|
||||
// be "reblogged" by us.
|
||||
suite.Equal(`{
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
},
|
||||
"bookmarked": true,
|
||||
"card": null,
|
||||
"content": "",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": true,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": null,
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": {
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "superseriousbusiness",
|
||||
"website": "https://superserious.business"
|
||||
},
|
||||
"bookmarked": true,
|
||||
"card": null,
|
||||
"content": "hello world! #welcome ! first post on the instance :rainbow: !",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [
|
||||
{
|
||||
"category": "reactions",
|
||||
"shortcode": "rainbow",
|
||||
"static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
|
||||
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
|
||||
"visible_in_picker": true
|
||||
}
|
||||
],
|
||||
"favourited": true,
|
||||
"favourites_count": 1,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": "en",
|
||||
"media_attachments": [
|
||||
{
|
||||
"blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj",
|
||||
"description": "Black and white image of some 50's style text saying: Welcome On Board",
|
||||
"id": "01F8MH6NEM8D7527KZAECTCR76",
|
||||
"meta": {
|
||||
"focus": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"original": {
|
||||
"aspect": 1.9047619,
|
||||
"height": 630,
|
||||
"size": "1200x630",
|
||||
"width": 1200
|
||||
},
|
||||
"small": {
|
||||
"aspect": 1.9104477,
|
||||
"height": 268,
|
||||
"size": "512x268",
|
||||
"width": 512
|
||||
}
|
||||
},
|
||||
"preview_remote_url": null,
|
||||
"preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp",
|
||||
"remote_url": null,
|
||||
"text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
|
||||
"type": "image",
|
||||
"url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg"
|
||||
}
|
||||
],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": null,
|
||||
"reblogged": true,
|
||||
"reblogs_count": 1,
|
||||
"replies_count": 1,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [
|
||||
{
|
||||
"name": "welcome",
|
||||
"url": "http://localhost:8080/tags/welcome"
|
||||
}
|
||||
],
|
||||
"text": "hello world! #welcome ! first post on the instance :rainbow: !",
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "public"
|
||||
},
|
||||
"reblogged": true,
|
||||
"reblogs_count": 0,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "public"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
var (
|
||||
targetStatus = suite.testStatuses["local_account_1_status_5"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_1"]
|
||||
user = suite.testUsers["local_account_1"]
|
||||
account = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
testStatus := suite.testStatuses["local_account_1_status_5"]
|
||||
testAccount := suite.testAccounts["local_account_1"]
|
||||
testUser := suite.testUsers["local_account_1"]
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, testUser)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, testAccount)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", testStatus.ID, 1)), nil)
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
// We should have OK from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusOK, recorder.Code)
|
||||
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: testStatus.ID,
|
||||
},
|
||||
}
|
||||
|
||||
suite.statusModule.StatusBoostPOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
|
||||
responseStatus := &apimodel.Status{}
|
||||
err = json.Unmarshal(b, responseStatus)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.False(responseStatus.Sensitive)
|
||||
suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility)
|
||||
|
||||
suite.Empty(responseStatus.SpoilerText)
|
||||
suite.Empty(responseStatus.Content)
|
||||
suite.Equal("the_mighty_zork", responseStatus.Account.Username)
|
||||
suite.Len(responseStatus.MediaAttachments, 0)
|
||||
suite.Len(responseStatus.Mentions, 0)
|
||||
suite.Len(responseStatus.Emojis, 0)
|
||||
suite.Len(responseStatus.Tags, 0)
|
||||
|
||||
suite.NotNil(responseStatus.Application)
|
||||
suite.Equal("really cool gts application", responseStatus.Application.Name)
|
||||
|
||||
suite.NotNil(responseStatus.Reblog)
|
||||
suite.Equal(1, responseStatus.Reblog.ReblogsCount)
|
||||
suite.Equal(0, responseStatus.Reblog.FavouritesCount)
|
||||
suite.Equal(testStatus.Content, responseStatus.Reblog.Content)
|
||||
suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText)
|
||||
suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID)
|
||||
suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility)
|
||||
suite.Empty(responseStatus.Reblog.MediaAttachments)
|
||||
suite.Empty(responseStatus.Reblog.Tags)
|
||||
suite.Empty(responseStatus.Reblog.Emojis)
|
||||
suite.True(responseStatus.Reblogged)
|
||||
suite.True(responseStatus.Reblog.Reblogged)
|
||||
suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name)
|
||||
// Target status should now
|
||||
// be "reblogged" by us.
|
||||
suite.Equal(`{
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": false,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"author",
|
||||
"followers",
|
||||
"mentioned",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"author",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"author",
|
||||
"followers",
|
||||
"mentioned",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": null,
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": {
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "hi!",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": false,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"author",
|
||||
"followers",
|
||||
"mentioned",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"author",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"author",
|
||||
"followers",
|
||||
"mentioned",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": "en",
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": null,
|
||||
"reblogged": true,
|
||||
"reblogs_count": 1,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"text": "hi!",
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "private"
|
||||
},
|
||||
"reblogged": true,
|
||||
"reblogs_count": 0,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "private"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
// try to boost a status that's not boostable / visible to us
|
||||
// Try to boost a status that's
|
||||
// not boostable / visible to us.
|
||||
func (suite *StatusBoostTestSuite) TestPostUnboostable() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
var (
|
||||
targetStatus = suite.testStatuses["local_account_2_status_4"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_1"]
|
||||
user = suite.testUsers["local_account_1"]
|
||||
account = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
targetStatus := suite.testStatuses["local_account_2_status_4"]
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
}
|
||||
|
||||
suite.statusModule.StatusBoostPOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
// We should have 403 from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusForbidden, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
suite.Equal(`{"error":"Forbidden: you do not have permission to boost this status"}`, string(b))
|
||||
// We should have a helpful message.
|
||||
suite.Equal(`{
|
||||
"error": "Forbidden: you do not have permission to boost this status"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
// try to boost a status that's not visible to the user
|
||||
// Try to boost a status that's not visible to the user.
|
||||
func (suite *StatusBoostTestSuite) TestPostNotVisible() {
|
||||
// stop local_account_2 following zork
|
||||
err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, >smodel.Follow{})
|
||||
suite.NoError(err)
|
||||
|
||||
t := suite.testTokens["local_account_2"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
// Stop local_account_2 following zork.
|
||||
err := suite.db.DeleteFollowByID(
|
||||
context.Background(),
|
||||
suite.testFollows["local_account_2_local_account_1"].ID,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.statusModule.StatusBoostPOSTHandler(ctx)
|
||||
var (
|
||||
// This is a mutual only status and
|
||||
// these accounts aren't mutuals anymore.
|
||||
targetStatus = suite.testStatuses["local_account_1_status_3"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_2"]
|
||||
user = suite.testUsers["local_account_2"]
|
||||
account = suite.testAccounts["local_account_2"]
|
||||
)
|
||||
|
||||
// check response
|
||||
suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// We should have 404 from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusNotFound, recorder.Code)
|
||||
|
||||
// We should have a helpful message.
|
||||
suite.Equal(`{
|
||||
"error": "Not Found: target status not found"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
// Boost a status that's pending approval by us.
|
||||
func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() {
|
||||
var (
|
||||
targetStatus = suite.testStatuses["admin_account_status_5"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_2"]
|
||||
user = suite.testUsers["local_account_2"]
|
||||
account = suite.testAccounts["local_account_2"]
|
||||
)
|
||||
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// We should have OK from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusOK, recorder.Code)
|
||||
|
||||
// Target status should now
|
||||
// be "reblogged" by us.
|
||||
suite.Equal(`{
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": false,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": null,
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": {
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "superseriousbusiness",
|
||||
"website": "https://superserious.business"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "<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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
// 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/>.
|
||||
|
||||
//go:build !nometrics
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
|
|
|
|||
31
internal/api/metrics/no_metrics.go
Normal file
31
internal/api/metrics/no_metrics.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// 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/>.
|
||||
|
||||
//go:build nometrics
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Module struct{}
|
||||
|
||||
func New() *Module { return &Module{} }
|
||||
|
||||
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
}
|
||||
17
internal/cache/db.go
vendored
17
internal/cache/db.go
vendored
|
|
@ -18,6 +18,8 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"codeberg.org/gruf/go-structr"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/cache/domain"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
|
|
@ -136,6 +138,14 @@ type DBCaches struct {
|
|||
// Instance provides access to the gtsmodel Instance database cache.
|
||||
Instance StructCache[*gtsmodel.Instance]
|
||||
|
||||
// LocalInstance provides caching for
|
||||
// simple + common local instance queries.
|
||||
LocalInstance struct {
|
||||
Domains atomic.Pointer[int]
|
||||
Statuses atomic.Pointer[int]
|
||||
Users atomic.Pointer[int]
|
||||
}
|
||||
|
||||
// InteractionRequest provides access to the gtsmodel InteractionRequest database cache.
|
||||
InteractionRequest StructCache[*gtsmodel.InteractionRequest]
|
||||
|
||||
|
|
@ -849,9 +859,10 @@ func (c *Caches) initInstance() {
|
|||
{Fields: "ID"},
|
||||
{Fields: "Domain"},
|
||||
},
|
||||
MaxSize: cap,
|
||||
IgnoreErr: ignoreErrors,
|
||||
Copy: copyF,
|
||||
MaxSize: cap,
|
||||
IgnoreErr: ignoreErrors,
|
||||
Copy: copyF,
|
||||
Invalidate: c.OnInvalidateInstance,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
14
internal/cache/invalidate.go
vendored
14
internal/cache/invalidate.go
vendored
|
|
@ -19,6 +19,7 @@ package cache
|
|||
|
||||
import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// Below are cache invalidation hooks between other caches,
|
||||
|
|
@ -178,6 +179,11 @@ func (c *Caches) OnInvalidateFollowRequest(followReq *gtsmodel.FollowRequest) {
|
|||
)
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateInstance(instance *gtsmodel.Instance) {
|
||||
// Invalidate the local domains count.
|
||||
c.DB.LocalInstance.Domains.Store(nil)
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateList(list *gtsmodel.List) {
|
||||
// Invalidate list IDs cache.
|
||||
c.DB.ListIDs.Invalidate(
|
||||
|
|
@ -255,6 +261,11 @@ func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) {
|
|||
// Invalidate cache of attached poll ID.
|
||||
c.DB.Poll.Invalidate("ID", status.PollID)
|
||||
}
|
||||
|
||||
if util.PtrOrZero(status.Local) {
|
||||
// Invalidate the local statuses count.
|
||||
c.DB.LocalInstance.Statuses.Store(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateStatusBookmark(bookmark *gtsmodel.StatusBookmark) {
|
||||
|
|
@ -271,6 +282,9 @@ func (c *Caches) OnInvalidateUser(user *gtsmodel.User) {
|
|||
// Invalidate local account ID cached visibility.
|
||||
c.Visibility.Invalidate("ItemID", user.AccountID)
|
||||
c.Visibility.Invalidate("RequesterID", user.AccountID)
|
||||
|
||||
// Invalidate the local users count.
|
||||
c.DB.LocalInstance.Users.Store(nil)
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ type instanceDB struct {
|
|||
}
|
||||
|
||||
func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int, error) {
|
||||
localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
|
||||
|
||||
if localhost {
|
||||
// Check for a cached instance user count, if so return this.
|
||||
if n := i.state.Caches.DB.LocalInstance.Users.Load(); n != nil {
|
||||
return *n, nil
|
||||
}
|
||||
}
|
||||
|
||||
q := i.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")).
|
||||
|
|
@ -46,7 +55,7 @@ func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int
|
|||
Where("? != ?", bun.Ident("account.username"), domain).
|
||||
Where("? IS NULL", bun.Ident("account.suspended_at"))
|
||||
|
||||
if domain == config.GetHost() || domain == config.GetAccountDomain() {
|
||||
if localhost {
|
||||
// If the domain is *this* domain, just
|
||||
// count where the domain field is null.
|
||||
q = q.Where("? IS NULL", bun.Ident("account.domain"))
|
||||
|
|
@ -58,15 +67,30 @@ func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if localhost {
|
||||
// Update cached instance users account value.
|
||||
i.state.Caches.DB.LocalInstance.Users.Store(&count)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (int, error) {
|
||||
localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
|
||||
|
||||
if localhost {
|
||||
// Check for a cached instance statuses count, if so return this.
|
||||
if n := i.state.Caches.DB.LocalInstance.Statuses.Load(); n != nil {
|
||||
return *n, nil
|
||||
}
|
||||
}
|
||||
|
||||
q := i.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status"))
|
||||
|
||||
if domain == config.GetHost() || domain == config.GetAccountDomain() {
|
||||
if localhost {
|
||||
// if the domain is *this* domain, just count where local is true
|
||||
q = q.Where("? = ?", bun.Ident("status.local"), true)
|
||||
} else {
|
||||
|
|
@ -83,15 +107,30 @@ func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if localhost {
|
||||
// Update cached instance statuses account value.
|
||||
i.state.Caches.DB.LocalInstance.Statuses.Store(&count)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (i *instanceDB) CountInstanceDomains(ctx context.Context, domain string) (int, error) {
|
||||
localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
|
||||
|
||||
if localhost {
|
||||
// Check for a cached instance domains count, if so return this.
|
||||
if n := i.state.Caches.DB.LocalInstance.Domains.Load(); n != nil {
|
||||
return *n, nil
|
||||
}
|
||||
}
|
||||
|
||||
q := i.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("instances"), bun.Ident("instance"))
|
||||
|
||||
if domain == config.GetHost() {
|
||||
if localhost {
|
||||
// if the domain is *this* domain, just count other instances it knows about
|
||||
// exclude domains that are blocked
|
||||
q = q.
|
||||
|
|
@ -106,6 +145,12 @@ func (i *instanceDB) CountInstanceDomains(ctx context.Context, domain string) (i
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if localhost {
|
||||
// Update cached instance domains account value.
|
||||
i.state.Caches.DB.LocalInstance.Domains.Store(&count)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
|
|
@ -215,13 +260,15 @@ func (i *instanceDB) PopulateInstance(ctx context.Context, instance *gtsmodel.In
|
|||
}
|
||||
|
||||
func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instance) error {
|
||||
// Normalize the domain as punycode
|
||||
var err error
|
||||
|
||||
// Normalize the domain as punycode
|
||||
instance.Domain, err = util.Punify(instance.Domain)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error punifying domain %s: %w", instance.Domain, err)
|
||||
}
|
||||
|
||||
// Store the new instance model in database, invalidating cache.
|
||||
return i.state.Caches.DB.Instance.Store(instance, func() error {
|
||||
_, err := i.db.NewInsert().Model(instance).Exec(ctx)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -71,17 +71,26 @@ func NewSender() (Sender, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
username := config.GetSMTPUsername()
|
||||
password := config.GetSMTPPassword()
|
||||
host := config.GetSMTPHost()
|
||||
port := config.GetSMTPPort()
|
||||
from := config.GetSMTPFrom()
|
||||
msgIDHost := config.GetHost()
|
||||
var (
|
||||
username = config.GetSMTPUsername()
|
||||
password = config.GetSMTPPassword()
|
||||
host = config.GetSMTPHost()
|
||||
port = config.GetSMTPPort()
|
||||
from = config.GetSMTPFrom()
|
||||
msgIDHost = config.GetHost()
|
||||
smtpAuth smtp.Auth
|
||||
)
|
||||
|
||||
if username == "" || password == "" {
|
||||
smtpAuth = nil
|
||||
} else {
|
||||
smtpAuth = smtp.PlainAuth("", username, password, host)
|
||||
}
|
||||
|
||||
return &sender{
|
||||
hostAddress: fmt.Sprintf("%s:%d", host, port),
|
||||
from: from,
|
||||
auth: smtp.PlainAuth("", username, password, host),
|
||||
auth: smtpAuth,
|
||||
msgIDHost: msgIDHost,
|
||||
template: t,
|
||||
}, nil
|
||||
|
|
|
|||
|
|
@ -104,18 +104,20 @@ func (f *Filter) isStatusVisible(
|
|||
return false, nil
|
||||
}
|
||||
|
||||
if util.PtrOrValue(status.PendingApproval, false) {
|
||||
if util.PtrOrZero(status.PendingApproval) {
|
||||
// Use a different visibility heuristic
|
||||
// for pending approval statuses.
|
||||
return f.isPendingStatusVisible(ctx,
|
||||
return isPendingStatusVisible(
|
||||
requester, status,
|
||||
)
|
||||
), nil
|
||||
}
|
||||
|
||||
if requester == nil {
|
||||
// Use a different visibility
|
||||
// heuristic for unauthed requests.
|
||||
return f.isStatusVisibleUnauthed(ctx, status)
|
||||
return f.isStatusVisibleUnauthed(
|
||||
ctx, status,
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -210,45 +212,42 @@ func (f *Filter) isStatusVisible(
|
|||
}
|
||||
}
|
||||
|
||||
func (f *Filter) isPendingStatusVisible(
|
||||
_ context.Context,
|
||||
requester *gtsmodel.Account,
|
||||
status *gtsmodel.Status,
|
||||
) (bool, error) {
|
||||
// isPendingStatusVisible returns whether a status pending approval is visible to requester.
|
||||
func isPendingStatusVisible(requester *gtsmodel.Account, status *gtsmodel.Status) bool {
|
||||
if requester == nil {
|
||||
// Any old tom, dick, and harry can't
|
||||
// see pending-approval statuses,
|
||||
// no matter what their visibility.
|
||||
return false, nil
|
||||
return false
|
||||
}
|
||||
|
||||
if status.AccountID == requester.ID {
|
||||
// This is requester's status,
|
||||
// so they can always see it.
|
||||
return true, nil
|
||||
return true
|
||||
}
|
||||
|
||||
if status.InReplyToAccountID == requester.ID {
|
||||
// This status replies to requester,
|
||||
// so they can always see it (else
|
||||
// they can't approve it).
|
||||
return true, nil
|
||||
return true
|
||||
}
|
||||
|
||||
if status.BoostOfAccountID == requester.ID {
|
||||
// This status boosts requester,
|
||||
// so they can always see it.
|
||||
return true, nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Nobody else can see this.
|
||||
return false, nil
|
||||
// Nobody else
|
||||
// can see this.
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *Filter) isStatusVisibleUnauthed(
|
||||
ctx context.Context,
|
||||
status *gtsmodel.Status,
|
||||
) (bool, error) {
|
||||
// isStatusVisibleUnauthed returns whether status is visible without any unauthenticated account.
|
||||
func (f *Filter) isStatusVisibleUnauthed(ctx context.Context, status *gtsmodel.Status) (bool, error) {
|
||||
|
||||
// For remote accounts, only show
|
||||
// Public statuses via the web.
|
||||
if status.Account.IsRemote() {
|
||||
|
|
@ -275,8 +274,7 @@ func (f *Filter) isStatusVisibleUnauthed(
|
|||
}
|
||||
}
|
||||
|
||||
webVisibility := status.Account.Settings.WebVisibility
|
||||
switch webVisibility {
|
||||
switch webvis := status.Account.Settings.WebVisibility; webvis {
|
||||
|
||||
// public_only: status must be Public.
|
||||
case gtsmodel.VisibilityPublic:
|
||||
|
|
@ -296,7 +294,7 @@ func (f *Filter) isStatusVisibleUnauthed(
|
|||
default:
|
||||
return false, gtserror.Newf(
|
||||
"unrecognized web visibility for account %s: %s",
|
||||
status.Account.ID, webVisibility,
|
||||
status.Account.ID, webvis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,9 +48,6 @@ var (
|
|||
|
||||
// ErrReservedAddr is returned if a dialed address resolves to an IP within a blocked or reserved net.
|
||||
ErrReservedAddr = errors.New("dial within blocked / reserved IP range")
|
||||
|
||||
// ErrBodyTooLarge is returned when a received response body is above predefined limit (default 40MB).
|
||||
ErrBodyTooLarge = errors.New("body size too large")
|
||||
)
|
||||
|
||||
// Config provides configuration details for setting up a new
|
||||
|
|
@ -302,7 +299,6 @@ func (c *Client) do(r *Request) (rsp *http.Response, retry bool, err error) {
|
|||
if errorsv2.IsV2(err,
|
||||
context.DeadlineExceeded,
|
||||
context.Canceled,
|
||||
ErrBodyTooLarge,
|
||||
ErrReservedAddr,
|
||||
) {
|
||||
// Non-retryable errors.
|
||||
|
|
|
|||
|
|
@ -224,8 +224,11 @@ func ffprobe(ctx context.Context, filepath string) (*result, error) {
|
|||
// Show specifically stream codec names, types, frame rate, duration, dimens, and pixel format.
|
||||
"stream=codec_name,codec_type,r_frame_rate,duration_ts,width,height,pix_fmt" + ":" +
|
||||
|
||||
// Show orientation.
|
||||
"tags=orientation",
|
||||
// Show orientation tag.
|
||||
"tags=orientation" + ":" +
|
||||
|
||||
// Show rotation data.
|
||||
"side_data=rotation",
|
||||
|
||||
// Limit to reading the first
|
||||
// 1s of data looking for "rotation"
|
||||
|
|
@ -490,7 +493,7 @@ func (res *ffprobeResult) Process() (*result, error) {
|
|||
}
|
||||
|
||||
// Check extra packet / frame information
|
||||
// for provided orientation (not always set).
|
||||
// for provided orientation (if provided).
|
||||
for _, pf := range res.PacketsAndFrames {
|
||||
|
||||
// Ensure frame contains tags.
|
||||
|
|
@ -498,23 +501,24 @@ func (res *ffprobeResult) Process() (*result, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Ensure orientation not
|
||||
// already been specified.
|
||||
if r.orientation != 0 {
|
||||
return nil, errors.New("multiple sets of orientation data")
|
||||
}
|
||||
|
||||
// Trim any space from orientation value.
|
||||
str := strings.TrimSpace(pf.Tags.Orientation)
|
||||
|
||||
// Parse as integer value.
|
||||
i, _ := strconv.Atoi(str)
|
||||
if i < 0 || i >= 9 {
|
||||
orient, _ := strconv.Atoi(str)
|
||||
if orient < 0 || orient >= 9 {
|
||||
return nil, errors.New("invalid orientation data")
|
||||
}
|
||||
|
||||
// Set orientation.
|
||||
r.orientation = i
|
||||
// Ensure different value has
|
||||
// not already been specified.
|
||||
if r.orientation != 0 &&
|
||||
orient != r.orientation {
|
||||
return nil, errors.New("multiple sets of orientation / rotation data")
|
||||
}
|
||||
|
||||
// Set new orientation.
|
||||
r.orientation = orient
|
||||
}
|
||||
|
||||
// Preallocate streams to max possible lengths.
|
||||
|
|
@ -554,6 +558,57 @@ func (res *ffprobeResult) Process() (*result, error) {
|
|||
framerate = float32(num / den)
|
||||
}
|
||||
|
||||
// Check for embedded sidedata
|
||||
// which may contain rotation data.
|
||||
for _, d := range s.SideDataList {
|
||||
|
||||
// Ensure frame side
|
||||
// data IS rotation data.
|
||||
if d.Rotation == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Drop any decimal
|
||||
// rotation value.
|
||||
rot := int(d.Rotation)
|
||||
|
||||
// Round rotation to multiple of 90.
|
||||
// More granularity is not needed.
|
||||
if q := rot % 90; q > 45 {
|
||||
rot += (90 - q)
|
||||
} else {
|
||||
rot -= q
|
||||
}
|
||||
|
||||
// Drop any value above 360
|
||||
// or below -360, these are
|
||||
// just repeat full turns.
|
||||
//
|
||||
// Then convert to
|
||||
// orientation value.
|
||||
var orient int
|
||||
switch rot % 360 {
|
||||
case 0:
|
||||
orient = orientationNormal
|
||||
case 90, -270:
|
||||
orient = orientationRotate90
|
||||
case 180:
|
||||
orient = orientationRotate180
|
||||
case 270, -90:
|
||||
orient = orientationRotate270
|
||||
}
|
||||
|
||||
// Ensure different value has
|
||||
// not already been specified.
|
||||
if r.orientation != 0 &&
|
||||
orient != r.orientation {
|
||||
return nil, errors.New("multiple sets of orientation / rotation data")
|
||||
}
|
||||
|
||||
// Set new orientation.
|
||||
r.orientation = orient
|
||||
}
|
||||
|
||||
// Append video stream data to result.
|
||||
r.video = append(r.video, videoStream{
|
||||
stream: stream{codec: s.CodecName},
|
||||
|
|
@ -580,6 +635,7 @@ type ffprobeResult struct {
|
|||
type ffprobePacketOrFrame struct {
|
||||
Type string `json:"type"`
|
||||
Tags ffprobeTags `json:"tags"`
|
||||
// SideDataList []ffprobeSideData `json:"side_data_list"`
|
||||
}
|
||||
|
||||
type ffprobeTags struct {
|
||||
|
|
@ -587,13 +643,18 @@ type ffprobeTags struct {
|
|||
}
|
||||
|
||||
type ffprobeStream struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
DurationTS uint `json:"duration_ts"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
DurationTS uint `json:"duration_ts"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
SideDataList []ffprobeSideData `json:"side_data_list"`
|
||||
}
|
||||
|
||||
type ffprobeSideData struct {
|
||||
Rotation float64 `json:"rotation"`
|
||||
}
|
||||
|
||||
type ffprobeFormat struct {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
|
|
@ -29,25 +29,11 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
allowMatches = matchstats{m: make(map[string]uint64)}
|
||||
blockMatches = matchstats{m: make(map[string]uint64)}
|
||||
// errors set on gin context by header filter middleware.
|
||||
errHeaderNotAllowed = errors.New("header did not match allow filter")
|
||||
errHeaderBlocked = errors.New("header matched block filter")
|
||||
)
|
||||
|
||||
// matchstats is a simple statistics
|
||||
// counter for header filter matches.
|
||||
// TODO: replace with otel.
|
||||
type matchstats struct {
|
||||
m map[string]uint64
|
||||
l sync.Mutex
|
||||
}
|
||||
|
||||
func (m *matchstats) Add(hdr, regex string) {
|
||||
m.l.Lock()
|
||||
key := hdr + ":" + regex
|
||||
m.m[key]++
|
||||
m.l.Unlock()
|
||||
}
|
||||
|
||||
// HeaderFilter returns a gin middleware handler that provides HTTP
|
||||
// request blocking (filtering) based on database allow / block filters.
|
||||
func HeaderFilter(state *state.State) gin.HandlerFunc {
|
||||
|
|
@ -83,6 +69,7 @@ func headerFilterAllowMode(state *state.State) func(c *gin.Context) {
|
|||
}
|
||||
|
||||
if block {
|
||||
_ = c.Error(errHeaderBlocked)
|
||||
respondBlocked(c)
|
||||
return
|
||||
}
|
||||
|
|
@ -95,6 +82,7 @@ func headerFilterAllowMode(state *state.State) func(c *gin.Context) {
|
|||
}
|
||||
|
||||
if notAllow {
|
||||
_ = c.Error(errHeaderNotAllowed)
|
||||
respondBlocked(c)
|
||||
return
|
||||
}
|
||||
|
|
@ -129,6 +117,7 @@ func headerFilterBlockMode(state *state.State) func(c *gin.Context) {
|
|||
}
|
||||
|
||||
if block {
|
||||
_ = c.Error(errHeaderBlocked)
|
||||
respondBlocked(c)
|
||||
return
|
||||
}
|
||||
|
|
@ -146,7 +135,7 @@ func isHeaderBlocked(state *state.State, c *gin.Context) (bool, error) {
|
|||
)
|
||||
|
||||
// Perform an explicit is-blocked check on request header.
|
||||
key, expr, err := state.DB.BlockHeaderRegularMatch(ctx, hdr)
|
||||
key, _, err := state.DB.BlockHeaderRegularMatch(ctx, hdr)
|
||||
switch err {
|
||||
case nil:
|
||||
break
|
||||
|
|
@ -161,12 +150,10 @@ func isHeaderBlocked(state *state.State, c *gin.Context) (bool, error) {
|
|||
}
|
||||
|
||||
if key != "" {
|
||||
if expr != "" {
|
||||
// Increment block matches stat.
|
||||
// TODO: replace expvar with build
|
||||
// taggable metrics types in State{}.
|
||||
blockMatches.Add(key, expr)
|
||||
}
|
||||
// if expr != "" {
|
||||
// // TODO: replace expvar with build
|
||||
// // taggable metrics types in State{}.
|
||||
// }
|
||||
|
||||
// A header was matched against!
|
||||
// i.e. this request is blocked.
|
||||
|
|
@ -183,7 +170,7 @@ func isHeaderAllowed(state *state.State, c *gin.Context) (bool, error) {
|
|||
)
|
||||
|
||||
// Perform an explicit is-allowed check on request header.
|
||||
key, expr, err := state.DB.AllowHeaderRegularMatch(ctx, hdr)
|
||||
key, _, err := state.DB.AllowHeaderRegularMatch(ctx, hdr)
|
||||
switch err {
|
||||
case nil:
|
||||
break
|
||||
|
|
@ -198,12 +185,10 @@ func isHeaderAllowed(state *state.State, c *gin.Context) (bool, error) {
|
|||
}
|
||||
|
||||
if key != "" {
|
||||
if expr != "" {
|
||||
// Increment allow matches stat.
|
||||
// TODO: replace expvar with build
|
||||
// taggable metrics types in State{}.
|
||||
allowMatches.Add(key, expr)
|
||||
}
|
||||
// if expr != "" {
|
||||
// // TODO: replace expvar with build
|
||||
// // taggable metrics types in State{}.
|
||||
// }
|
||||
|
||||
// A header was matched against!
|
||||
// i.e. this request is allowed.
|
||||
|
|
@ -220,7 +205,7 @@ func isHeaderNotAllowed(state *state.State, c *gin.Context) (bool, error) {
|
|||
)
|
||||
|
||||
// Perform an explicit is-NOT-allowed check on request header.
|
||||
key, expr, err := state.DB.AllowHeaderInverseMatch(ctx, hdr)
|
||||
key, _, err := state.DB.AllowHeaderInverseMatch(ctx, hdr)
|
||||
switch err {
|
||||
case nil:
|
||||
break
|
||||
|
|
@ -235,12 +220,10 @@ func isHeaderNotAllowed(state *state.State, c *gin.Context) (bool, error) {
|
|||
}
|
||||
|
||||
if key != "" {
|
||||
if expr != "" {
|
||||
// Increment allow matches stat.
|
||||
// TODO: replace expvar with build
|
||||
// taggable metrics types in State{}.
|
||||
allowMatches.Add(key, expr)
|
||||
}
|
||||
// if expr != "" {
|
||||
// // TODO: replace expvar with build
|
||||
// // taggable metrics types in State{}.
|
||||
// }
|
||||
|
||||
// A header was matched against!
|
||||
// i.e. request is NOT allowed.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ func (p *Processor) GetTargetAccountBy(
|
|||
// Fetch the target account from db.
|
||||
target, err := getTargetFromDB()
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("error getting from db: %w", err)
|
||||
return nil, false, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ func (p *Processor) GetTargetAccountBy(
|
|||
// Check whether target account is visible to requesting account.
|
||||
visible, err = p.visFilter.AccountVisible(ctx, requester, target)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error checking visibility: %w", err)
|
||||
return nil, false, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +130,8 @@ func (p *Processor) GetVisibleTargetAccount(
|
|||
return target, nil
|
||||
}
|
||||
|
||||
// GetAPIAccount fetches the appropriate API account model depending on whether requester = target.
|
||||
// GetAPIAccount fetches the appropriate API account
|
||||
// model depending on whether requester = target.
|
||||
func (p *Processor) GetAPIAccount(
|
||||
ctx context.Context,
|
||||
requester *gtsmodel.Account,
|
||||
|
|
@ -148,14 +151,15 @@ func (p *Processor) GetAPIAccount(
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting account: %w", err)
|
||||
err := gtserror.Newf("error converting: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return apiAcc, nil
|
||||
}
|
||||
|
||||
// GetAPIAccountBlocked fetches the limited "blocked" account model for given target.
|
||||
// GetAPIAccountBlocked fetches the limited
|
||||
// "blocked" account model for given target.
|
||||
func (p *Processor) GetAPIAccountBlocked(
|
||||
ctx context.Context,
|
||||
targetAcc *gtsmodel.Account,
|
||||
|
|
@ -165,7 +169,7 @@ func (p *Processor) GetAPIAccountBlocked(
|
|||
) {
|
||||
apiAccount, err := p.converter.AccountToAPIAccountBlocked(ctx, targetAcc)
|
||||
if err != nil {
|
||||
err = gtserror.Newf("error converting account: %w", err)
|
||||
err := gtserror.Newf("error converting: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
return apiAccount, nil
|
||||
|
|
@ -182,7 +186,7 @@ func (p *Processor) GetAPIAccountSensitive(
|
|||
) {
|
||||
apiAccount, err := p.converter.AccountToAPIAccountSensitive(ctx, targetAcc)
|
||||
if err != nil {
|
||||
err = gtserror.Newf("error converting account: %w", err)
|
||||
err := gtserror.Newf("error converting: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
return apiAccount, nil
|
||||
|
|
@ -226,8 +230,7 @@ func (p *Processor) getVisibleAPIAccounts(
|
|||
) []*apimodel.Account {
|
||||
// Start new log entry with
|
||||
// the above calling func's name.
|
||||
l := log.
|
||||
WithContext(ctx).
|
||||
l := log.WithContext(ctx).
|
||||
WithField("caller", log.Caller(calldepth+1))
|
||||
|
||||
// Preallocate slice according to expected length.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"
|
||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
|
|
@ -50,6 +51,7 @@ func (p *Processor) GetTargetStatusBy(
|
|||
// Fetch the target status from db.
|
||||
target, err := getTargetFromDB()
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("error getting from db: %w", err)
|
||||
return nil, false, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ func (p *Processor) GetTargetStatusBy(
|
|||
// Check whether target status is visible to requesting account.
|
||||
visible, err = p.visFilter.StatusVisible(ctx, requester, target)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error checking visibility: %w", err)
|
||||
return nil, false, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
|
|
@ -174,14 +177,83 @@ func (p *Processor) GetAPIStatus(
|
|||
apiStatus *apimodel.Status,
|
||||
errWithCode gtserror.WithCode,
|
||||
) {
|
||||
apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, statusfilter.FilterContextNone, nil, nil)
|
||||
apiStatus, err := p.converter.StatusToAPIStatus(ctx,
|
||||
target,
|
||||
requester,
|
||||
statusfilter.FilterContextNone,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
err = gtserror.Newf("error converting status: %w", err)
|
||||
err := gtserror.Newf("error converting: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
return apiStatus, nil
|
||||
}
|
||||
|
||||
// GetVisibleAPIStatuses converts a slice of statuses to API
|
||||
// model statuses, filtering according to visibility to requester
|
||||
// along with given filter context, filters and user mutes.
|
||||
//
|
||||
// Please note that all errors will be logged at ERROR level,
|
||||
// but will not be returned. Callers are likely to run into
|
||||
// show-stopping errors in the lead-up to this function.
|
||||
func (p *Processor) GetVisibleAPIStatuses(
|
||||
ctx context.Context,
|
||||
requester *gtsmodel.Account,
|
||||
statuses []*gtsmodel.Status,
|
||||
filterContext statusfilter.FilterContext,
|
||||
filters []*gtsmodel.Filter,
|
||||
userMutes []*gtsmodel.UserMute,
|
||||
) []apimodel.Status {
|
||||
|
||||
// Start new log entry with
|
||||
// the calling function name
|
||||
// as a field in each entry.
|
||||
l := log.WithContext(ctx).
|
||||
WithField("caller", log.Caller(3))
|
||||
|
||||
// Compile mutes to useable user mutes for type converter.
|
||||
compUserMutes := usermute.NewCompiledUserMuteList(userMutes)
|
||||
|
||||
// Iterate filtered statuses for conversion to API model.
|
||||
apiStatuses := make([]apimodel.Status, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
|
||||
// Check whether status is visible to requester.
|
||||
visible, err := p.visFilter.StatusVisible(ctx,
|
||||
requester,
|
||||
status,
|
||||
)
|
||||
if err != nil {
|
||||
l.Errorf("error checking visibility: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !visible {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert to API status, taking mute / filter into account.
|
||||
apiStatus, err := p.converter.StatusToAPIStatus(ctx,
|
||||
status,
|
||||
requester,
|
||||
filterContext,
|
||||
filters,
|
||||
compUserMutes,
|
||||
)
|
||||
if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
|
||||
l.Errorf("error converting: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Append converted status to return slice.
|
||||
apiStatuses = append(apiStatuses, *apiStatus)
|
||||
}
|
||||
|
||||
return apiStatuses
|
||||
}
|
||||
|
||||
// InvalidateTimelinedStatus is a shortcut function for invalidating the cached
|
||||
// representation one status in the home timeline and all list timelines of the
|
||||
// given accountID. It should only be called in cases where a status update
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
|
@ -308,22 +307,7 @@ func (p *Processor) ContextGet(
|
|||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
convert := func(
|
||||
ctx context.Context,
|
||||
status *gtsmodel.Status,
|
||||
requestingAccount *gtsmodel.Account,
|
||||
) (*apimodel.Status, error) {
|
||||
return p.converter.StatusToAPIStatus(
|
||||
ctx,
|
||||
status,
|
||||
requestingAccount,
|
||||
statusfilter.FilterContextThread,
|
||||
filters,
|
||||
usermute.NewCompiledUserMuteList(mutes),
|
||||
)
|
||||
}
|
||||
|
||||
// Retrieve the thread context.
|
||||
// Retrieve the full thread context.
|
||||
threadContext, errWithCode := p.contextGet(
|
||||
ctx,
|
||||
requester,
|
||||
|
|
@ -333,34 +317,27 @@ func (p *Processor) ContextGet(
|
|||
return nil, errWithCode
|
||||
}
|
||||
|
||||
apiContext := &apimodel.ThreadContext{
|
||||
Ancestors: make([]apimodel.Status, 0, len(threadContext.ancestors)),
|
||||
Descendants: make([]apimodel.Status, 0, len(threadContext.descendants)),
|
||||
}
|
||||
var apiContext apimodel.ThreadContext
|
||||
|
||||
// Convert ancestors + filter
|
||||
// out ones that aren't visible.
|
||||
for _, status := range threadContext.ancestors {
|
||||
if v, err := p.visFilter.StatusVisible(ctx, requester, status); err == nil && v {
|
||||
status, err := convert(ctx, status, requester)
|
||||
if err == nil {
|
||||
apiContext.Ancestors = append(apiContext.Ancestors, *status)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convert and filter the thread context ancestors.
|
||||
apiContext.Ancestors = p.c.GetVisibleAPIStatuses(ctx,
|
||||
requester,
|
||||
threadContext.ancestors,
|
||||
statusfilter.FilterContextThread,
|
||||
filters,
|
||||
mutes,
|
||||
)
|
||||
|
||||
// Convert descendants + filter
|
||||
// out ones that aren't visible.
|
||||
for _, status := range threadContext.descendants {
|
||||
if v, err := p.visFilter.StatusVisible(ctx, requester, status); err == nil && v {
|
||||
status, err := convert(ctx, status, requester)
|
||||
if err == nil {
|
||||
apiContext.Descendants = append(apiContext.Descendants, *status)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convert and filter the thread context descendants
|
||||
apiContext.Descendants = p.c.GetVisibleAPIStatuses(ctx,
|
||||
requester,
|
||||
threadContext.descendants,
|
||||
statusfilter.FilterContextThread,
|
||||
filters,
|
||||
mutes,
|
||||
)
|
||||
|
||||
return apiContext, nil
|
||||
return &apiContext, nil
|
||||
}
|
||||
|
||||
// WebContextGet is like ContextGet, but is explicitly
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
72
internal/processing/status/util.go
Normal file
72
internal/processing/status/util.go
Normal 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
|
||||
}
|
||||
|
|
@ -384,8 +384,9 @@ func (s *Surface) timelineStatus(
|
|||
) (bool, error) {
|
||||
|
||||
// Ingest status into given timeline using provided function.
|
||||
if inserted, err := ingest(ctx, timelineID, status); err != nil {
|
||||
err = gtserror.Newf("error ingesting status %s: %w", status.ID, err)
|
||||
if inserted, err := ingest(ctx, timelineID, status); err != nil &&
|
||||
!errors.Is(err, statusfilter.ErrHideStatus) {
|
||||
err := gtserror.Newf("error ingesting status %s: %w", status.ID, err)
|
||||
return false, err
|
||||
} else if !inserted {
|
||||
// Nothing more to do.
|
||||
|
|
@ -400,15 +401,19 @@ func (s *Surface) timelineStatus(
|
|||
filters,
|
||||
mutes,
|
||||
)
|
||||
if err != nil {
|
||||
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
|
||||
if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
|
||||
err := gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
|
||||
return true, err
|
||||
}
|
||||
|
||||
// The status was inserted so stream it to the user.
|
||||
s.Stream.Update(ctx, account, apiStatus, streamType)
|
||||
if apiStatus != nil {
|
||||
// The status was inserted so stream it to the user.
|
||||
s.Stream.Update(ctx, account, apiStatus, streamType)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
// Status was hidden.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// timelineAndNotifyStatusForTagFollowers inserts the status into the
|
||||
|
|
|
|||
|
|
@ -473,19 +473,20 @@ const (
|
|||
|
||||
type TypeUtilsTestSuite struct {
|
||||
suite.Suite
|
||||
db db.DB
|
||||
state state.State
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testPeople map[string]vocab.ActivityStreamsPerson
|
||||
testEmojis map[string]*gtsmodel.Emoji
|
||||
testReports map[string]*gtsmodel.Report
|
||||
testMentions map[string]*gtsmodel.Mention
|
||||
testPollVotes map[string]*gtsmodel.PollVote
|
||||
testFilters map[string]*gtsmodel.Filter
|
||||
testFilterKeywords map[string]*gtsmodel.FilterKeyword
|
||||
testFilterStatues map[string]*gtsmodel.FilterStatus
|
||||
db db.DB
|
||||
state state.State
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testPeople map[string]vocab.ActivityStreamsPerson
|
||||
testEmojis map[string]*gtsmodel.Emoji
|
||||
testReports map[string]*gtsmodel.Report
|
||||
testMentions map[string]*gtsmodel.Mention
|
||||
testPollVotes map[string]*gtsmodel.PollVote
|
||||
testFilters map[string]*gtsmodel.Filter
|
||||
testFilterKeywords map[string]*gtsmodel.FilterKeyword
|
||||
testFilterStatues map[string]*gtsmodel.FilterStatus
|
||||
testInteractionRequests map[string]*gtsmodel.InteractionRequest
|
||||
|
||||
typeconverter *typeutils.Converter
|
||||
}
|
||||
|
|
@ -512,6 +513,7 @@ func (suite *TypeUtilsTestSuite) SetupTest() {
|
|||
suite.testFilters = testrig.NewTestFilters()
|
||||
suite.testFilterKeywords = testrig.NewTestFilterKeywords()
|
||||
suite.testFilterStatues = testrig.NewTestFilterStatuses()
|
||||
suite.testInteractionRequests = testrig.NewTestInteractionRequests()
|
||||
suite.typeconverter = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
|
|
|
|||
|
|
@ -803,26 +803,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,
|
||||
|
|
@ -833,7 +862,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)
|
||||
}
|
||||
|
|
@ -842,23 +871,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
|
||||
|
|
@ -1991,7 +2040,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)
|
||||
}
|
||||
|
|
@ -2628,8 +2690,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)
|
||||
|
|
@ -2638,13 +2700,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,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
package typeutils_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
|
@ -1709,6 +1710,130 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction
|
|||
}`, string(b))
|
||||
}
|
||||
|
||||
func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() {
|
||||
var (
|
||||
testStatus = suite.testStatuses["admin_account_status_5"]
|
||||
requestingAccount = suite.testAccounts["local_account_2"]
|
||||
)
|
||||
|
||||
apiStatus, err := suite.typeconverter.StatusToAPIStatus(
|
||||
context.Background(),
|
||||
testStatus,
|
||||
requestingAccount,
|
||||
statusfilter.FilterContextNone,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// We want to see the HTML in
|
||||
// the status so don't escape it.
|
||||
out := new(bytes.Buffer)
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(apiStatus); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(`{
|
||||
"id": "01J5QVB9VC76NPPRQ207GG4DRZ",
|
||||
"created_at": "2024-02-20T10:41:37.000Z",
|
||||
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
|
||||
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"visibility": "unlisted",
|
||||
"language": null,
|
||||
"uri": "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
|
||||
"url": "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
|
||||
"replies_count": 0,
|
||||
"reblogs_count": 0,
|
||||
"favourites_count": 0,
|
||||
"favourited": false,
|
||||
"reblogged": false,
|
||||
"muted": false,
|
||||
"bookmarked": false,
|
||||
"pinned": false,
|
||||
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><hr><p><i lang=\"en\">ℹ️ Note from localhost:8080: This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR</a>.</i></p>",
|
||||
"reblog": null,
|
||||
"application": {
|
||||
"name": "superseriousbusiness",
|
||||
"website": "https://superserious.business"
|
||||
},
|
||||
"account": {
|
||||
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
|
||||
"username": "admin",
|
||||
"acct": "admin",
|
||||
"display_name": "",
|
||||
"locked": false,
|
||||
"discoverable": true,
|
||||
"bot": false,
|
||||
"created_at": "2022-05-17T13:10:59.000Z",
|
||||
"note": "",
|
||||
"url": "http://localhost:8080/@admin",
|
||||
"avatar": "",
|
||||
"avatar_static": "",
|
||||
"header": "http://localhost:8080/assets/default_header.webp",
|
||||
"header_static": "http://localhost:8080/assets/default_header.webp",
|
||||
"followers_count": 1,
|
||||
"following_count": 1,
|
||||
"statuses_count": 4,
|
||||
"last_status_at": "2021-10-20T10:41:37.000Z",
|
||||
"emojis": [],
|
||||
"fields": [],
|
||||
"enable_rss": true,
|
||||
"roles": [
|
||||
{
|
||||
"id": "admin",
|
||||
"name": "admin",
|
||||
"color": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"media_attachments": [],
|
||||
"mentions": [
|
||||
{
|
||||
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
|
||||
"username": "1happyturtle",
|
||||
"url": "http://localhost:8080/@1happyturtle",
|
||||
"acct": "1happyturtle"
|
||||
}
|
||||
],
|
||||
"tags": [],
|
||||
"emojis": [],
|
||||
"card": null,
|
||||
"poll": null,
|
||||
"text": "Hi @1happyturtle, can I reply?",
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
}
|
||||
}
|
||||
`, out.String())
|
||||
}
|
||||
|
||||
func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
|
||||
testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"]
|
||||
apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment)
|
||||
|
|
@ -2996,6 +3121,244 @@ func (suite *InternalToFrontendTestSuite) TestRelationshipFollowRequested() {
|
|||
}`, string(b))
|
||||
}
|
||||
|
||||
func (suite *InternalToFrontendTestSuite) TestIntReqToAPI() {
|
||||
requestingAccount := suite.testAccounts["local_account_2"]
|
||||
adminReport, err := suite.typeconverter.InteractionReqToAPIInteractionReq(
|
||||
context.Background(),
|
||||
suite.testInteractionRequests["admin_account_reply_turtle"],
|
||||
requestingAccount,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(adminReport, "", " ")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(`{
|
||||
"id": "01J5QVXCCEATJYSXM9H6MZT4JR",
|
||||
"type": "reply",
|
||||
"created_at": "2024-02-20T10:41:37.000Z",
|
||||
"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": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"id": "01F8MHC8VWDRBQR0N1BATDDEM5",
|
||||
"created_at": "2021-10-20T10:40:37.000Z",
|
||||
"in_reply_to_id": null,
|
||||
"in_reply_to_account_id": null,
|
||||
"sensitive": true,
|
||||
"spoiler_text": "you won't be able to reply to this without my approval",
|
||||
"visibility": "unlisted",
|
||||
"language": "en",
|
||||
"uri": "http://localhost:8080/users/1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5",
|
||||
"url": "http://localhost:8080/@1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5",
|
||||
"replies_count": 1,
|
||||
"reblogs_count": 0,
|
||||
"favourites_count": 0,
|
||||
"favourited": false,
|
||||
"reblogged": false,
|
||||
"muted": false,
|
||||
"bookmarked": false,
|
||||
"pinned": false,
|
||||
"content": "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢",
|
||||
"reblog": null,
|
||||
"application": {
|
||||
"name": "kindaweird",
|
||||
"website": "https://kindaweird.app"
|
||||
},
|
||||
"account": {
|
||||
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
|
||||
"username": "1happyturtle",
|
||||
"acct": "1happyturtle",
|
||||
"display_name": "happy little turtle :3",
|
||||
"locked": true,
|
||||
"discoverable": false,
|
||||
"bot": false,
|
||||
"created_at": "2022-06-04T13:12:00.000Z",
|
||||
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
|
||||
"url": "http://localhost:8080/@1happyturtle",
|
||||
"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": 8,
|
||||
"last_status_at": "2021-07-28T08:40:37.000Z",
|
||||
"emojis": [],
|
||||
"fields": [
|
||||
{
|
||||
"name": "should you follow me?",
|
||||
"value": "maybe!",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"name": "age",
|
||||
"value": "120",
|
||||
"verified_at": null
|
||||
}
|
||||
],
|
||||
"hide_collections": true
|
||||
},
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"tags": [],
|
||||
"emojis": [],
|
||||
"card": null,
|
||||
"poll": null,
|
||||
"text": "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢",
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"author",
|
||||
"me"
|
||||
],
|
||||
"with_approval": [
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"reply": {
|
||||
"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": "\u003cp\u003eHi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003e1happyturtle\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e, can I reply?\u003c/p\u003e",
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, string(b))
|
||||
}
|
||||
|
||||
func TestInternalToFrontendTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(InternalToFrontendTestSuite))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package typeutils
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
|
|
@ -30,6 +31,8 @@ import (
|
|||
"github.com/k3a/html2text"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/language"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
|
|
@ -187,6 +190,47 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att
|
|||
return text.SanitizeToHTML(note.String()), arr
|
||||
}
|
||||
|
||||
func (c *Converter) pendingReplyNote(
|
||||
ctx context.Context,
|
||||
s *gtsmodel.Status,
|
||||
) (string, error) {
|
||||
intReq, err := c.state.DB.GetInteractionRequestByInteractionURI(ctx, s.URI)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
// Something's gone wrong.
|
||||
err := gtserror.Newf("db error getting interaction request for %s: %w", s.URI, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// No interaction request present
|
||||
// for this status. Race condition?
|
||||
if intReq == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var (
|
||||
proto = config.GetProtocol()
|
||||
host = config.GetHost()
|
||||
|
||||
// Build the settings panel URL at which the user
|
||||
// can view + approve/reject the interaction request.
|
||||
//
|
||||
// Eg., https://example.org/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR
|
||||
settingsURL = proto + "://" + host + "/settings/user/interaction_requests/" + intReq.ID
|
||||
)
|
||||
|
||||
var note strings.Builder
|
||||
note.WriteString(`<hr>`)
|
||||
note.WriteString(`<p><i lang="en">ℹ️ Note from ` + host + `: `)
|
||||
note.WriteString(`This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: `)
|
||||
note.WriteString(`<a href="` + settingsURL + `" `)
|
||||
note.WriteString(`rel="noreferrer noopener" target="_blank">`)
|
||||
note.WriteString(settingsURL)
|
||||
note.WriteString(`</a>.`)
|
||||
note.WriteString(`</i></p>`)
|
||||
|
||||
return text.SanitizeToHTML(note.String()), nil
|
||||
}
|
||||
|
||||
// ContentToContentLanguage tries to
|
||||
// extract a content string and language
|
||||
// tag string from the given intermediary
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue