mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-01 06:50:00 +00:00
This reverts commit 9616f46424
.
This commit is contained in:
parent
9616f46424
commit
ac9adb172b
21 changed files with 106 additions and 616 deletions
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account_test
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account_test
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
|
@ -16,4 +16,4 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account_test
|
||||
package account
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
const (
|
||||
// BasePath is the base API path for this module
|
||||
BasePath = "/api/v1/admin"
|
||||
BasePath = "/api/v1/admin"
|
||||
// EmojiPath is used for posting/deleting custom emojis
|
||||
EmojiPath = BasePath + "/custom_emojis"
|
||||
)
|
||||
|
|
|
@ -16,6 +16,6 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package app_test
|
||||
package app
|
||||
|
||||
// TODO: write tests
|
|
@ -32,9 +32,9 @@
|
|||
|
||||
const (
|
||||
// AuthSignInPath is the API path for users to sign in through
|
||||
AuthSignInPath = "/auth/sign_in"
|
||||
AuthSignInPath = "/auth/sign_in"
|
||||
// OauthTokenPath is the API path to use for granting token requests to users with valid credentials
|
||||
OauthTokenPath = "/oauth/token"
|
||||
OauthTokenPath = "/oauth/token"
|
||||
// OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user)
|
||||
OauthAuthorizePath = "/oauth/authorize"
|
||||
)
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package auth_test
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
|
@ -39,7 +39,7 @@
|
|||
// MediaSizeKey is the url key for the desired media size--original/small/static
|
||||
MediaSizeKey = "media_size"
|
||||
// FileNameKey is the actual filename being sought. Will usually be a UUID then something like .jpeg
|
||||
FileNameKey = "file_name"
|
||||
FileNameKey = "file_name"
|
||||
)
|
||||
|
||||
// FileServer implements the RESTAPIModule interface.
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package fileserver_test
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -90,7 +90,7 @@ func (suite *ServeFileTestSuite) TearDownSuite() {
|
|||
|
||||
func (suite *ServeFileTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
|
@ -16,19 +16,28 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package media_test
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
|
@ -84,7 +93,7 @@ func (suite *MediaCreateTestSuite) TearDownSuite() {
|
|||
|
||||
func (suite *MediaCreateTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
|
@ -104,80 +113,80 @@ func (suite *MediaCreateTestSuite) TearDownTest() {
|
|||
|
||||
func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() {
|
||||
|
||||
// // set up the context for the request
|
||||
// t := suite.testTokens["local_account_1"]
|
||||
// oauthToken := oauth.TokenToOauthToken(t)
|
||||
// recorder := httptest.NewRecorder()
|
||||
// ctx, _ := gin.CreateTestContext(recorder)
|
||||
// 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"])
|
||||
// set up the context for the request
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.TokenToOauthToken(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
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"])
|
||||
|
||||
// // see what's in storage *before* the request
|
||||
// storageKeysBeforeRequest, err := suite.storage.ListKeys()
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// see what's in storage *before* the request
|
||||
storageKeysBeforeRequest, err := suite.storage.ListKeys()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// // create the request
|
||||
// buf, w, err := testrig.CreateMultipartFormData("file", "../../../testrig/media/test-jpeg.jpg", map[string]string{
|
||||
// "description": "this is a test image -- a cool background from somewhere",
|
||||
// "focus": "-0.5,0.5",
|
||||
// })
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting
|
||||
// ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
|
||||
// create the request
|
||||
buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
|
||||
"description": "this is a test image -- a cool background from somewhere",
|
||||
"focus": "-0.5,0.5",
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
|
||||
|
||||
// // do the actual request
|
||||
// suite.mediaModule.MediaCreatePOSTHandler(ctx)
|
||||
// do the actual request
|
||||
suite.mediaModule.MediaCreatePOSTHandler(ctx)
|
||||
|
||||
// // check what's in storage *after* the request
|
||||
// storageKeysAfterRequest, err := suite.storage.ListKeys()
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// check what's in storage *after* the request
|
||||
storageKeysAfterRequest, err := suite.storage.ListKeys()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// // check response
|
||||
// suite.EqualValues(http.StatusAccepted, recorder.Code)
|
||||
// check response
|
||||
suite.EqualValues(http.StatusAccepted, recorder.Code)
|
||||
|
||||
// result := recorder.Result()
|
||||
// defer result.Body.Close()
|
||||
// b, err := ioutil.ReadAll(result.Body)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// fmt.Println(string(b))
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
fmt.Println(string(b))
|
||||
|
||||
// attachmentReply := &mastomodel.Attachment{}
|
||||
// err = json.Unmarshal(b, attachmentReply)
|
||||
// assert.NoError(suite.T(), err)
|
||||
attachmentReply := &mastomodel.Attachment{}
|
||||
err = json.Unmarshal(b, attachmentReply)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description)
|
||||
// assert.Equal(suite.T(), "image", attachmentReply.Type)
|
||||
// assert.EqualValues(suite.T(), mastomodel.MediaMeta{
|
||||
// Original: mastomodel.MediaDimensions{
|
||||
// Width: 1920,
|
||||
// Height: 1080,
|
||||
// Size: "1920x1080",
|
||||
// Aspect: 1.7777778,
|
||||
// },
|
||||
// Small: mastomodel.MediaDimensions{
|
||||
// Width: 256,
|
||||
// Height: 144,
|
||||
// Size: "256x144",
|
||||
// Aspect: 1.7777778,
|
||||
// },
|
||||
// Focus: mastomodel.MediaFocus{
|
||||
// X: -0.5,
|
||||
// Y: 0.5,
|
||||
// },
|
||||
// }, attachmentReply.Meta)
|
||||
// assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash)
|
||||
// assert.NotEmpty(suite.T(), attachmentReply.ID)
|
||||
// assert.NotEmpty(suite.T(), attachmentReply.URL)
|
||||
// assert.NotEmpty(suite.T(), attachmentReply.PreviewURL)
|
||||
// assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail
|
||||
assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description)
|
||||
assert.Equal(suite.T(), "image", attachmentReply.Type)
|
||||
assert.EqualValues(suite.T(), mastomodel.MediaMeta{
|
||||
Original: mastomodel.MediaDimensions{
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Size: "1920x1080",
|
||||
Aspect: 1.7777778,
|
||||
},
|
||||
Small: mastomodel.MediaDimensions{
|
||||
Width: 256,
|
||||
Height: 144,
|
||||
Size: "256x144",
|
||||
Aspect: 1.7777778,
|
||||
},
|
||||
Focus: mastomodel.MediaFocus{
|
||||
X: -0.5,
|
||||
Y: 0.5,
|
||||
},
|
||||
}, attachmentReply.Meta)
|
||||
assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash)
|
||||
assert.NotEmpty(suite.T(), attachmentReply.ID)
|
||||
assert.NotEmpty(suite.T(), attachmentReply.URL)
|
||||
assert.NotEmpty(suite.T(), attachmentReply.PreviewURL)
|
||||
assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail
|
||||
}
|
||||
|
||||
func TestMediaCreateTestSuite(t *testing.T) {
|
|
@ -37,9 +37,9 @@
|
|||
|
||||
const (
|
||||
// IDKey is for status UUIDs
|
||||
IDKey = "id"
|
||||
IDKey = "id"
|
||||
// BasePath is the base path for serving the status API
|
||||
BasePath = "/api/v1/statuses"
|
||||
BasePath = "/api/v1/statuses"
|
||||
// BasePathWithID is just the base path with the ID key in it.
|
||||
// Use this anywhere you need to know the ID of the status being queried.
|
||||
BasePathWithID = BasePath + "/:" + IDKey
|
||||
|
@ -48,31 +48,31 @@
|
|||
ContextPath = BasePathWithID + "/context"
|
||||
|
||||
// FavouritedPath is for seeing who's faved a given status
|
||||
FavouritedPath = BasePathWithID + "/favourited_by"
|
||||
FavouritedPath = BasePathWithID + "/favourited_by"
|
||||
// FavouritePath is for posting a fave on a status
|
||||
FavouritePath = BasePathWithID + "/favourite"
|
||||
FavouritePath = BasePathWithID + "/favourite"
|
||||
// UnfavouritePath is for removing a fave from a status
|
||||
UnfavouritePath = BasePathWithID + "/unfavourite"
|
||||
|
||||
// RebloggedPath is for seeing who's boosted a given status
|
||||
RebloggedPath = BasePathWithID + "/reblogged_by"
|
||||
// ReblogPath is for boosting/reblogging a given status
|
||||
ReblogPath = BasePathWithID + "/reblog"
|
||||
ReblogPath = BasePathWithID + "/reblog"
|
||||
// UnreblogPath is for undoing a boost/reblog of a given status
|
||||
UnreblogPath = BasePathWithID + "/unreblog"
|
||||
UnreblogPath = BasePathWithID + "/unreblog"
|
||||
|
||||
// BookmarkPath is for creating a bookmark on a given status
|
||||
BookmarkPath = BasePathWithID + "/bookmark"
|
||||
BookmarkPath = BasePathWithID + "/bookmark"
|
||||
// UnbookmarkPath is for removing a bookmark from a given status
|
||||
UnbookmarkPath = BasePathWithID + "/unbookmark"
|
||||
|
||||
// MutePath is for muting a given status so that notifications will no longer be received about it.
|
||||
MutePath = BasePathWithID + "/mute"
|
||||
MutePath = BasePathWithID + "/mute"
|
||||
// UnmutePath is for undoing an existing mute
|
||||
UnmutePath = BasePathWithID + "/unmute"
|
||||
|
||||
// PinPath is for pinning a status to an account profile so that it's the first thing people see
|
||||
PinPath = BasePathWithID + "/pin"
|
||||
PinPath = BasePathWithID + "/pin"
|
||||
// UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy
|
||||
UnpinPath = BasePathWithID + "/unpin"
|
||||
)
|
||||
|
@ -107,8 +107,6 @@ func (m *Module) Route(r router.Router) error {
|
|||
r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
|
||||
r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler)
|
||||
|
||||
r.AttachHandler(http.MethodPost, ReblogPath, m.StatusReblogPOSTHandler)
|
||||
|
||||
r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -1,176 +0,0 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
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 (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// StatusReblogPOSTHandler handles boost/reblog requests against a given status ID
|
||||
func (m *Module) StatusReblogPOSTHandler(c *gin.Context) {
|
||||
l := m.log.WithFields(logrus.Fields{
|
||||
"func": "StatusReblogPOSTHandler",
|
||||
"request_uri": c.Request.RequestURI,
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
"origin_ip": c.ClientIP(),
|
||||
})
|
||||
l.Debugf("entering function")
|
||||
|
||||
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||
if err != nil {
|
||||
l.Debug("not authed so can't boost status")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID)
|
||||
targetStatus := >smodel.Status{}
|
||||
if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
|
||||
l.Errorf("error fetching status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID)
|
||||
targetAccount := >smodel.Account{}
|
||||
if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
|
||||
l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to get relevant accounts")
|
||||
relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
|
||||
if err != nil {
|
||||
l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
l.Trace("going to see if status is visible")
|
||||
visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil {
|
||||
l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
if !visible {
|
||||
l.Trace("status is not visible so cannot be boosted")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
// is the status boostable?
|
||||
if !targetStatus.VisibilityAdvanced.Boostable {
|
||||
l.Debug("status is not boostable")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not boostable", targetStatusID)})
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE BOOST -- it is valid and we will try to create it
|
||||
*/
|
||||
|
||||
// it's visible! it's boostable! so let's boost the FUCK out of it
|
||||
// first we create a new status and add some basic info to it -- this will be the wrapper for the boosted status
|
||||
|
||||
// the wrapper won't use the same ID as the boosted status so we generate some new UUIDs
|
||||
uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host)
|
||||
boostWrapperStatusID := uuid.NewString()
|
||||
boostWrapperStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, boostWrapperStatusID)
|
||||
boostWrapperStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, boostWrapperStatusID)
|
||||
|
||||
boostWrapperStatus := >smodel.Status{
|
||||
ID: boostWrapperStatusID,
|
||||
URI: boostWrapperStatusURI,
|
||||
URL: boostWrapperStatusURL,
|
||||
|
||||
// the boosted status is not created now, but the boost certainly is
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Local: true, // always local since this is being done through the client API
|
||||
AccountID: authed.Account.ID,
|
||||
CreatedWithApplicationID: authed.Application.ID,
|
||||
|
||||
// replies can be boosted, but boosts are never replies
|
||||
InReplyToID: "",
|
||||
InReplyToAccountID: "",
|
||||
|
||||
// these will all be wrapped in the boosted status so set them empty here
|
||||
Attachments: []string{},
|
||||
Tags: []string{},
|
||||
Mentions: []string{},
|
||||
Emojis: []string{},
|
||||
|
||||
// the below fields will be taken from the target status
|
||||
Content: util.HTMLFormat(targetStatus.Content), // take content from target status
|
||||
ContentWarning: targetStatus.ContentWarning, // same warning as the target status
|
||||
ActivityStreamsType: targetStatus.ActivityStreamsType, // same activitystreams type as target status
|
||||
Sensitive: targetStatus.Sensitive,
|
||||
Language: targetStatus.Language,
|
||||
Text: targetStatus.Text,
|
||||
BoostOfID: targetStatus.ID,
|
||||
Visibility: targetStatus.Visibility,
|
||||
VisibilityAdvanced: targetStatus.VisibilityAdvanced,
|
||||
|
||||
// attach these here for convenience -- the boosted status/account won't go in the DB
|
||||
// but they're needed in the distributor and for the frontend. Since we have them, we can
|
||||
// attach them so we don't need to fetch them again later (save some DB calls)
|
||||
GTSBoostedStatus: targetStatus,
|
||||
GTSBoostedAccount: targetAccount,
|
||||
}
|
||||
|
||||
// put the boost in the database
|
||||
if err := m.db.Put(boostWrapperStatus); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc
|
||||
m.distributor.FromClientAPI() <- distributor.FromClientAPI{
|
||||
APObjectType: gtsmodel.ActivityStreamsNote,
|
||||
APActivityType: gtsmodel.ActivityStreamsAnnounce, // boost/reblog is an 'announce' activity
|
||||
Activity: boostWrapperStatus,
|
||||
}
|
||||
|
||||
// return the frontend representation of the new status to the submitter
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(boostWrapperStatus, authed.Account, authed.Account, targetAccount, nil, targetStatus)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mastoStatus)
|
||||
}
|
|
@ -1,265 +0,0 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
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_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type StatusReblogTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
log *logrus.Logger
|
||||
storage storage.Storage
|
||||
mastoConverter mastotypes.Converter
|
||||
mediaHandler media.Handler
|
||||
oauthServer oauth.Server
|
||||
distributor distributor.Distributor
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*oauth.Token
|
||||
testClients map[string]*oauth.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
|
||||
// module being tested
|
||||
statusModule *status.Module
|
||||
}
|
||||
|
||||
/*
|
||||
TEST INFRASTRUCTURE
|
||||
*/
|
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusReblogTestSuite) SetupSuite() {
|
||||
// setup standard items
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.log = testrig.NewTestLog()
|
||||
suite.storage = testrig.NewTestStorage()
|
||||
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||
suite.distributor = testrig.NewTestDistributor()
|
||||
|
||||
// setup module being tested
|
||||
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
|
||||
}
|
||||
|
||||
func (suite *StatusReblogTestSuite) TearDownSuite() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *StatusReblogTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testAttachments = testrig.NewTestAttachments()
|
||||
suite.testStatuses = testrig.NewTestStatuses()
|
||||
}
|
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
func (suite *StatusReblogTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
/*
|
||||
ACTUAL TESTS
|
||||
*/
|
||||
|
||||
// boost a status
|
||||
func (suite *StatusReblogTestSuite) TestPostReblog() {
|
||||
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.TokenToOauthToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
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(status.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
|
||||
// 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: status.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
}
|
||||
|
||||
suite.statusModule.StatusReblogPOSTHandler(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)
|
||||
|
||||
fmt.Println(string(b))
|
||||
|
||||
statusReply := &mastomodel.Status{}
|
||||
err = json.Unmarshal(b, statusReply)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
assert.False(suite.T(), statusReply.Sensitive)
|
||||
assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||
|
||||
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
|
||||
assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
|
||||
assert.Equal(suite.T(), "the_mighty_zork", statusReply.Account.Username)
|
||||
assert.Len(suite.T(), statusReply.MediaAttachments, 0)
|
||||
assert.Len(suite.T(), statusReply.Mentions, 0)
|
||||
assert.Len(suite.T(), statusReply.Emojis, 0)
|
||||
assert.Len(suite.T(), statusReply.Tags, 0)
|
||||
|
||||
assert.NotNil(suite.T(), statusReply.Application)
|
||||
assert.Equal(suite.T(), "really cool gts application", statusReply.Application.Name)
|
||||
|
||||
assert.NotNil(suite.T(), statusReply.Reblog)
|
||||
assert.Equal(suite.T(), 1, statusReply.Reblog.ReblogsCount)
|
||||
assert.Equal(suite.T(), 1, statusReply.Reblog.FavouritesCount)
|
||||
assert.Equal(suite.T(), targetStatus.Content, statusReply.Reblog.Content)
|
||||
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.Reblog.SpoilerText)
|
||||
assert.Equal(suite.T(), targetStatus.AccountID, statusReply.Reblog.Account.ID)
|
||||
assert.Len(suite.T(), statusReply.Reblog.MediaAttachments, 1)
|
||||
assert.Len(suite.T(), statusReply.Reblog.Tags, 1)
|
||||
assert.Len(suite.T(), statusReply.Reblog.Emojis, 1)
|
||||
assert.Equal(suite.T(), "superseriousbusiness", statusReply.Reblog.Application.Name)
|
||||
}
|
||||
|
||||
// try to boost a status that's not boostable
|
||||
func (suite *StatusReblogTestSuite) TestPostUnboostable() {
|
||||
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.TokenToOauthToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["local_account_2_status_4"]
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
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(status.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
|
||||
// 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: status.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
}
|
||||
|
||||
suite.statusModule.StatusReblogPOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unboostable statuses
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not boostable"}`, targetStatus.ID), string(b))
|
||||
}
|
||||
|
||||
// try to boost a status that's not visible to the user
|
||||
func (suite *StatusReblogTestSuite) TestPostNotVisible() {
|
||||
|
||||
t := suite.testTokens["local_account_2"]
|
||||
oauthToken := oauth.TokenToOauthToken(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, _ := gin.CreateTestContext(recorder)
|
||||
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(status.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
|
||||
// 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: status.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
}
|
||||
|
||||
suite.statusModule.StatusReblogPOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not found"}`, targetStatus.ID), string(b))
|
||||
}
|
||||
|
||||
func TestStatusReblogTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(StatusReblogTestSuite))
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status_test
|
||||
package status
|
||||
|
||||
import (
|
||||
"encoding/json"
|
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status_test
|
||||
package status
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
@ -96,7 +96,7 @@ func (suite *StatusFaveTestSuite) TearDownSuite() {
|
|||
|
||||
func (suite *StatusFaveTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status_test
|
||||
package status
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
@ -92,7 +92,7 @@ func (suite *StatusFavedByTestSuite) TearDownSuite() {
|
|||
|
||||
func (suite *StatusFavedByTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status_test
|
||||
package status
|
||||
|
||||
import (
|
||||
"testing"
|
|
@ -16,7 +16,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package status_test
|
||||
package status
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
@ -96,7 +96,7 @@ func (suite *StatusUnfaveTestSuite) TearDownSuite() {
|
|||
|
||||
func (suite *StatusUnfaveTestSuite) SetupTest() {
|
||||
testrig.StandardDBSetup(suite.db)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
|
@ -89,10 +89,6 @@ type Status struct {
|
|||
GTSReplyToStatus *Status `pg:"-"`
|
||||
// Account being replied to
|
||||
GTSReplyToAccount *Account `pg:"-"`
|
||||
// Status being boosted
|
||||
GTSBoostedStatus *Status `pg:"-"`
|
||||
// Account of the boosted status
|
||||
GTSBoostedAccount *Account `pg:"-"`
|
||||
}
|
||||
|
||||
// Visibility represents the visibility granularity of a status.
|
||||
|
|
|
@ -380,55 +380,7 @@ func (c *converter) StatusToMasto(
|
|||
}
|
||||
}
|
||||
|
||||
var mastoRebloggedStatus *mastotypes.Status
|
||||
if s.BoostOfID != "" {
|
||||
// the boosted status might have been set on this struct already so check first before doing db calls
|
||||
var gtsBoostedStatus *gtsmodel.Status
|
||||
if s.GTSBoostedStatus != nil {
|
||||
// it's set, great!
|
||||
gtsBoostedStatus = s.GTSBoostedStatus
|
||||
} else {
|
||||
// it's not set so fetch it from the db
|
||||
gtsBoostedStatus = >smodel.Status{}
|
||||
if err := c.db.GetByID(s.BoostOfID, gtsBoostedStatus); err != nil {
|
||||
return nil, fmt.Errorf("error getting boosted status with id %s: %s", s.BoostOfID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// the boosted account might have been set on this struct already or passed as a param so check first before doing db calls
|
||||
var gtsBoostedAccount *gtsmodel.Account
|
||||
if s.GTSBoostedAccount != nil {
|
||||
// it's set, great!
|
||||
gtsBoostedAccount = s.GTSBoostedAccount
|
||||
} else if boostOfAccount != nil {
|
||||
// it's been given as a param, great!
|
||||
gtsBoostedAccount = boostOfAccount
|
||||
} else if boostOfAccount == nil && s.GTSBoostedAccount == nil {
|
||||
// it's not set so fetch it from the db
|
||||
gtsBoostedAccount = >smodel.Account{}
|
||||
if err := c.db.GetByID(gtsBoostedStatus.AccountID, gtsBoostedAccount); err != nil {
|
||||
return nil, fmt.Errorf("error getting boosted account %s from status with id %s: %s", gtsBoostedStatus.AccountID, s.BoostOfID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// the boosted status might be a reply so check this
|
||||
var gtsBoostedReplyToAccount *gtsmodel.Account
|
||||
if gtsBoostedStatus.InReplyToAccountID != "" {
|
||||
gtsBoostedReplyToAccount = >smodel.Account{}
|
||||
if err := c.db.GetByID(gtsBoostedStatus.InReplyToAccountID, gtsBoostedReplyToAccount); err != nil {
|
||||
return nil, fmt.Errorf("error getting account that boosted status was a reply to: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if gtsBoostedStatus != nil || gtsBoostedAccount != nil {
|
||||
mastoRebloggedStatus, err = c.StatusToMasto(gtsBoostedStatus, gtsBoostedAccount, requestingAccount, nil, gtsBoostedReplyToAccount, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting boosted status to mastotype: %s", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("boost of id was set to %s but that status or account was nil", s.BoostOfID)
|
||||
}
|
||||
}
|
||||
var mastoRebloggedStatus *mastotypes.Status // TODO
|
||||
|
||||
var mastoApplication *mastotypes.Application
|
||||
if s.CreatedWithApplicationID != "" {
|
||||
|
|
|
@ -962,30 +962,6 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
|
|||
},
|
||||
ActivityStreamsType: gtsmodel.ActivityStreamsNote,
|
||||
},
|
||||
"local_account_2_status_4": {
|
||||
ID: "57e41a35-20da-4bc9-9cfd-db2089f924db",
|
||||
URI: "http://localhost:8080/users/1happyturtle/statuses/57e41a35-20da-4bc9-9cfd-db2089f924db",
|
||||
URL: "http://localhost:8080/@1happyturtle/statuses/57e41a35-20da-4bc9-9cfd-db2089f924db",
|
||||
Content: "🐢 this is a public status but I want it local only and not boostable 🐢",
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(-1 * time.Minute),
|
||||
Local: true,
|
||||
AccountID: "eecaad73-5703-426d-9312-276641daa31e",
|
||||
InReplyToID: "",
|
||||
BoostOfID: "",
|
||||
ContentWarning: "",
|
||||
Visibility: gtsmodel.VisibilityPublic,
|
||||
Sensitive: true,
|
||||
Language: "en",
|
||||
CreatedWithApplicationID: "6b0cd164-8497-4cd5-bec9-957886fac5df",
|
||||
VisibilityAdvanced: >smodel.VisibilityAdvanced{
|
||||
Federated: false,
|
||||
Boostable: false,
|
||||
Replyable: true,
|
||||
Likeable: true,
|
||||
},
|
||||
ActivityStreamsType: gtsmodel.ActivityStreamsNote,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue