Browse Source
This PR adds:
Statuses
New status creation.
View existing status
Delete a status
Fave a status
Unfave a status
See who's faved a status
Media
Upload media attachment and store/retrieve it
Upload custom emoji and store/retrieve it
Fileserver
Serve files from storage
Testing
Test models, testrig -- run a GTS test instance and play around with it.
pull/12/head
150 changed files with 8986 additions and 751 deletions
@ -0,0 +1,84 @@
|
||||
/* |
||||
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 admin |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule" |
||||
"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" |
||||
"github.com/superseriousbusiness/gotosocial/internal/media" |
||||
"github.com/superseriousbusiness/gotosocial/internal/router" |
||||
) |
||||
|
||||
const ( |
||||
basePath = "/api/v1/admin" |
||||
emojiPath = basePath + "/custom_emojis" |
||||
) |
||||
|
||||
type adminModule struct { |
||||
config *config.Config |
||||
db db.DB |
||||
mediaHandler media.MediaHandler |
||||
mastoConverter mastotypes.Converter |
||||
log *logrus.Logger |
||||
} |
||||
|
||||
// New returns a new account module
|
||||
func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { |
||||
return &adminModule{ |
||||
config: config, |
||||
db: db, |
||||
mediaHandler: mediaHandler, |
||||
mastoConverter: mastoConverter, |
||||
log: log, |
||||
} |
||||
} |
||||
|
||||
// Route attaches all routes from this module to the given router
|
||||
func (m *adminModule) Route(r router.Router) error { |
||||
r.AttachHandler(http.MethodPost, emojiPath, m.emojiCreatePOSTHandler) |
||||
return nil |
||||
} |
||||
|
||||
func (m *adminModule) CreateTables(db db.DB) error { |
||||
models := []interface{}{ |
||||
>smodel.User{}, |
||||
>smodel.Account{}, |
||||
>smodel.Follow{}, |
||||
>smodel.FollowRequest{}, |
||||
>smodel.Status{}, |
||||
>smodel.Application{}, |
||||
>smodel.EmailDomainBlock{}, |
||||
>smodel.MediaAttachment{}, |
||||
>smodel.Emoji{}, |
||||
} |
||||
|
||||
for _, m := range models { |
||||
if err := db.CreateTable(m); err != nil { |
||||
return fmt.Errorf("error creating table: %s", err) |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
@ -0,0 +1,130 @@
|
||||
/* |
||||
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 admin |
||||
|
||||
import ( |
||||
"bytes" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/sirupsen/logrus" |
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/media" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util" |
||||
) |
||||
|
||||
func (m *adminModule) emojiCreatePOSTHandler(c *gin.Context) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "emojiCreatePOSTHandler", |
||||
"request_uri": c.Request.RequestURI, |
||||
"user_agent": c.Request.UserAgent(), |
||||
"origin_ip": c.ClientIP(), |
||||
}) |
||||
|
||||
// make sure we're authed with an admin account
|
||||
authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
|
||||
if err != nil { |
||||
l.Debugf("couldn't auth: %s", err) |
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
if !authed.User.Admin { |
||||
l.Debugf("user %s not an admin", authed.User.ID) |
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) |
||||
return |
||||
} |
||||
|
||||
// extract the media create form from the request context
|
||||
l.Tracef("parsing request form: %+v", c.Request.Form) |
||||
form := &mastotypes.EmojiCreateRequest{} |
||||
if err := c.ShouldBind(form); err != nil { |
||||
l.Debugf("error parsing form %+v: %s", c.Request.Form, err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)}) |
||||
return |
||||
} |
||||
|
||||
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||
l.Tracef("validating form %+v", form) |
||||
if err := validateCreateEmoji(form); err != nil { |
||||
l.Debugf("error validating form: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// open the emoji and extract the bytes from it
|
||||
f, err := form.Image.Open() |
||||
if err != nil { |
||||
l.Debugf("error opening emoji: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)}) |
||||
return |
||||
} |
||||
buf := new(bytes.Buffer) |
||||
size, err := io.Copy(buf, f) |
||||
if err != nil { |
||||
l.Debugf("error reading emoji: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)}) |
||||
return |
||||
} |
||||
if size == 0 { |
||||
l.Debug("could not read provided emoji: size 0 bytes") |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"}) |
||||
return |
||||
} |
||||
|
||||
// allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using
|
||||
emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode) |
||||
if err != nil { |
||||
l.Debugf("error reading emoji: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)}) |
||||
return |
||||
} |
||||
|
||||
mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji) |
||||
if err != nil { |
||||
l.Debugf("error converting emoji to mastotype: %s", err) |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)}) |
||||
return |
||||
} |
||||
|
||||
if err := m.db.Put(emoji); err != nil { |
||||
l.Debugf("database error while processing emoji: %s", err) |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)}) |
||||
return |
||||
} |
||||
|
||||
c.JSON(http.StatusOK, mastoEmoji) |
||||
} |
||||
|
||||
func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error { |
||||
// check there actually is an image attached and it's not size 0
|
||||
if form.Image == nil || form.Image.Size == 0 { |
||||
return errors.New("no emoji given") |
||||
} |
||||
|
||||
// a very superficial check to see if the media size limit is exceeded
|
||||
if form.Image.Size > media.EmojiMaxBytes { |
||||
return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size) |
||||
} |
||||
|
||||
return util.ValidateEmojiShortcode(form.Shortcode) |
||||
} |
||||
@ -0,0 +1,243 @@
|
||||
/* |
||||
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 fileserver |
||||
|
||||
import ( |
||||
"bytes" |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/media" |
||||
) |
||||
|
||||
// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
|
||||
//
|
||||
// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
|
||||
// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
|
||||
func (m *FileServer) ServeFile(c *gin.Context) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "ServeFile", |
||||
"request_uri": c.Request.RequestURI, |
||||
"user_agent": c.Request.UserAgent(), |
||||
"origin_ip": c.ClientIP(), |
||||
}) |
||||
l.Trace("received request") |
||||
|
||||
// We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
|
||||
// "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
|
||||
// "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
|
||||
accountID := c.Param(AccountIDKey) |
||||
if accountID == "" { |
||||
l.Debug("missing accountID from request") |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
mediaType := c.Param(MediaTypeKey) |
||||
if mediaType == "" { |
||||
l.Debug("missing mediaType from request") |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
mediaSize := c.Param(MediaSizeKey) |
||||
if mediaSize == "" { |
||||
l.Debug("missing mediaSize from request") |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
fileName := c.Param(FileNameKey) |
||||
if fileName == "" { |
||||
l.Debug("missing fileName from request") |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// Only serve media types that are defined in our internal media module
|
||||
switch mediaType { |
||||
case media.MediaHeader, media.MediaAvatar, media.MediaAttachment: |
||||
m.serveAttachment(c, accountID, mediaType, mediaSize, fileName) |
||||
return |
||||
case media.MediaEmoji: |
||||
m.serveEmoji(c, accountID, mediaType, mediaSize, fileName) |
||||
return |
||||
} |
||||
l.Debugf("mediatype %s not recognized", mediaType) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
} |
||||
|
||||
func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "serveAttachment", |
||||
"request_uri": c.Request.RequestURI, |
||||
"user_agent": c.Request.UserAgent(), |
||||
"origin_ip": c.ClientIP(), |
||||
}) |
||||
|
||||
// This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static
|
||||
switch mediaSize { |
||||
case media.MediaOriginal, media.MediaSmall, media.MediaStatic: |
||||
default: |
||||
l.Debugf("mediasize %s not recognized", mediaSize) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// derive the media id and the file extension from the last part of the request
|
||||
spl := strings.Split(fileName, ".") |
||||
if len(spl) != 2 { |
||||
l.Debugf("filename %s not parseable", fileName) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
wantedMediaID := spl[0] |
||||
fileExtension := spl[1] |
||||
if wantedMediaID == "" || fileExtension == "" { |
||||
l.Debugf("filename %s not parseable", fileName) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
|
||||
attachment := >smodel.MediaAttachment{} |
||||
if err := m.db.GetByID(wantedMediaID, attachment); err != nil { |
||||
l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// make sure the given account id owns the requested attachment
|
||||
if accountID != attachment.AccountID { |
||||
l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
|
||||
var storagePath string |
||||
var contentType string |
||||
var contentLength int |
||||
switch mediaSize { |
||||
case media.MediaOriginal: |
||||
storagePath = attachment.File.Path |
||||
contentType = attachment.File.ContentType |
||||
contentLength = attachment.File.FileSize |
||||
case media.MediaSmall: |
||||
storagePath = attachment.Thumbnail.Path |
||||
contentType = attachment.Thumbnail.ContentType |
||||
contentLength = attachment.Thumbnail.FileSize |
||||
} |
||||
|
||||
// use the path listed on the attachment we pulled out of the database to retrieve the object from storage
|
||||
attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath) |
||||
if err != nil { |
||||
l.Debugf("error retrieving from storage: %s", err) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes))) |
||||
|
||||
// finally we can return with all the information we derived above
|
||||
c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{}) |
||||
} |
||||
|
||||
func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "serveEmoji", |
||||
"request_uri": c.Request.RequestURI, |
||||
"user_agent": c.Request.UserAgent(), |
||||
"origin_ip": c.ClientIP(), |
||||
}) |
||||
|
||||
// This corresponds to original-sized emoji as it was uploaded, or static
|
||||
switch mediaSize { |
||||
case media.MediaOriginal, media.MediaStatic: |
||||
default: |
||||
l.Debugf("mediasize %s not recognized", mediaSize) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// derive the media id and the file extension from the last part of the request
|
||||
spl := strings.Split(fileName, ".") |
||||
if len(spl) != 2 { |
||||
l.Debugf("filename %s not parseable", fileName) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
wantedEmojiID := spl[0] |
||||
fileExtension := spl[1] |
||||
if wantedEmojiID == "" || fileExtension == "" { |
||||
l.Debugf("filename %s not parseable", fileName) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
|
||||
emoji := >smodel.Emoji{} |
||||
if err := m.db.GetByID(wantedEmojiID, emoji); err != nil { |
||||
l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// make sure the instance account id owns the requested emoji
|
||||
instanceAccount := >smodel.Account{} |
||||
if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil { |
||||
l.Debugf("error fetching instance account: %s", err) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
if accountID != instanceAccount.ID { |
||||
l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
|
||||
var storagePath string |
||||
var contentType string |
||||
var contentLength int |
||||
switch mediaSize { |
||||
case media.MediaOriginal: |
||||
storagePath = emoji.ImagePath |
||||
contentType = emoji.ImageContentType |
||||
contentLength = emoji.ImageFileSize |
||||
case media.MediaStatic: |
||||
storagePath = emoji.ImageStaticPath |
||||
contentType = "image/png" |
||||
contentLength = emoji.ImageStaticFileSize |
||||
} |
||||
|
||||
// use the path listed on the emoji we pulled out of the database to retrieve the object from storage
|
||||
emojiBytes, err := m.storage.RetrieveFileFrom(storagePath) |
||||
if err != nil { |
||||
l.Debugf("error retrieving emoji from storage: %s", err) |
||||
c.String(http.StatusNotFound, "404 page not found") |
||||
return |
||||
} |
||||
|
||||
// finally we can return with all the information we derived above
|
||||
c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{}) |
||||
} |
||||
@ -0,0 +1,157 @@
|
||||
/* |
||||
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 test |
||||
|
||||
import ( |
||||
"context" |
||||
"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" |
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" |
||||
"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" |
||||
"github.com/superseriousbusiness/gotosocial/internal/media" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
"github.com/superseriousbusiness/gotosocial/internal/storage" |
||||
"github.com/superseriousbusiness/gotosocial/testrig" |
||||
) |
||||
|
||||
type ServeFileTestSuite struct { |
||||
// standard suite interfaces
|
||||
suite.Suite |
||||
config *config.Config |
||||
db db.DB |
||||
log *logrus.Logger |
||||
storage storage.Storage |
||||
mastoConverter mastotypes.Converter |
||||
mediaHandler media.MediaHandler |
||||
oauthServer oauth.Server |
||||
|
||||
// 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 |
||||
|
||||
// item being tested
|
||||
fileServer *fileserver.FileServer |
||||
} |
||||
|
||||
/* |
||||
TEST INFRASTRUCTURE |
||||
*/ |
||||
|
||||
func (suite *ServeFileTestSuite) 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) |
||||
|
||||
// setup module being tested
|
||||
suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer) |
||||
} |
||||
|
||||
func (suite *ServeFileTestSuite) TearDownSuite() { |
||||
if err := suite.db.Stop(context.Background()); err != nil { |
||||
logrus.Panicf("error closing db connection: %s", err) |
||||
} |
||||
} |
||||
|
||||
func (suite *ServeFileTestSuite) 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() |
||||
} |
||||
|
||||
func (suite *ServeFileTestSuite) TearDownTest() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
/* |
||||
ACTUAL TESTS |
||||
*/ |
||||
|
||||
func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() { |
||||
targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"] |
||||
assert.True(suite.T(), ok) |
||||
assert.NotNil(suite.T(), targetAttachment) |
||||
|
||||
recorder := httptest.NewRecorder() |
||||
ctx, _ := gin.CreateTestContext(recorder) |
||||
ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil) |
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the ServeFile function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{ |
||||
gin.Param{ |
||||
Key: fileserver.AccountIDKey, |
||||
Value: targetAttachment.AccountID, |
||||
}, |
||||
gin.Param{ |
||||
Key: fileserver.MediaTypeKey, |
||||
Value: media.MediaAttachment, |
||||
}, |
||||
gin.Param{ |
||||
Key: fileserver.MediaSizeKey, |
||||
Value: media.MediaOriginal, |
||||
}, |
||||
gin.Param{ |
||||
Key: fileserver.FileNameKey, |
||||
Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID), |
||||
}, |
||||
} |
||||
|
||||
// call the function we're testing and check status code
|
||||
suite.fileServer.ServeFile(ctx) |
||||
suite.EqualValues(http.StatusOK, recorder.Code) |
||||
|
||||
b, err := ioutil.ReadAll(recorder.Body) |
||||
assert.NoError(suite.T(), err) |
||||
assert.NotNil(suite.T(), b) |
||||
|
||||
fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path) |
||||
assert.NoError(suite.T(), err) |
||||
assert.NotNil(suite.T(), fileInStorage) |
||||
assert.Equal(suite.T(), b, fileInStorage) |
||||
} |
||||
|
||||
func TestServeFileTestSuite(t *testing.T) { |
||||
suite.Run(t, new(ServeFileTestSuite)) |
||||
} |
||||
@ -0,0 +1,73 @@
|
||||
/* |
||||
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 media |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule" |
||||
"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" |
||||
"github.com/superseriousbusiness/gotosocial/internal/media" |
||||
"github.com/superseriousbusiness/gotosocial/internal/router" |
||||
) |
||||
|
||||
const BasePath = "/api/v1/media" |
||||
|
||||
type MediaModule struct { |
||||
mediaHandler media.MediaHandler |
||||
config *config.Config |
||||
db db.DB |
||||
mastoConverter mastotypes.Converter |
||||
log *logrus.Logger |
||||
} |
||||
|
||||
// New returns a new auth module
|
||||
func New(db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { |
||||
return &MediaModule{ |
||||
mediaHandler: mediaHandler, |
||||
config: config, |
||||
db: db, |
||||
mastoConverter: mastoConverter, |
||||
log: log, |
||||
} |
||||
} |
||||
|
||||
// Route satisfies the RESTAPIModule interface
|
||||
func (m *MediaModule) Route(s router.Router) error { |
||||
s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler) |
||||
return nil |
||||
} |
||||
|
||||
func (m *MediaModule) CreateTables(db db.DB) error { |
||||
models := []interface{}{ |
||||
>smodel.MediaAttachment{}, |
||||
} |
||||
|
||||
for _, m := range models { |
||||
if err := db.CreateTable(m); err != nil { |
||||
return fmt.Errorf("error creating table: %s", err) |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
@ -0,0 +1,192 @@
|
||||
/* |
||||
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 media |
||||
|
||||
import ( |
||||
"bytes" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/superseriousbusiness/gotosocial/internal/config" |
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
) |
||||
|
||||
func (m *MediaModule) MediaCreatePOSTHandler(c *gin.Context) { |
||||
l := m.log.WithField("func", "statusCreatePOSTHandler") |
||||
authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything*
|
||||
if err != nil { |
||||
l.Debugf("couldn't auth: %s", err) |
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// First check this user/account is permitted to create media
|
||||
// There's no point continuing otherwise.
|
||||
if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { |
||||
l.Debugf("couldn't auth: %s", err) |
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) |
||||
return |
||||
} |
||||
|
||||
// extract the media create form from the request context
|
||||
l.Tracef("parsing request form: %s", c.Request.Form) |
||||
form := &mastotypes.AttachmentRequest{} |
||||
if err := c.ShouldBind(form); err != nil || form == nil { |
||||
l.Debugf("could not parse form from request: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) |
||||
return |
||||
} |
||||
|
||||
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||
l.Tracef("validating form %+v", form) |
||||
if err := validateCreateMedia(form, m.config.MediaConfig); err != nil { |
||||
l.Debugf("error validating form: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// open the attachment and extract the bytes from it
|
||||
f, err := form.File.Open() |
||||
if err != nil { |
||||
l.Debugf("error opening attachment: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)}) |
||||
return |
||||
} |
||||
buf := new(bytes.Buffer) |
||||
size, err := io.Copy(buf, f) |
||||
if err != nil { |
||||
l.Debugf("error reading attachment: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)}) |
||||
return |
||||
} |
||||
if size == 0 { |
||||
l.Debug("could not read provided attachment: size 0 bytes") |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"}) |
||||
return |
||||
} |
||||
|
||||
// allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using
|
||||
attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID) |
||||
if err != nil { |
||||
l.Debugf("error reading attachment: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)}) |
||||
return |
||||
} |
||||
|
||||
// now we need to add extra fields that the attachment processor doesn't know (from the form)
|
||||
// TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it)
|
||||
|
||||
// first description
|
||||
attachment.Description = form.Description |
||||
|
||||
// now parse the focus parameter
|
||||
// TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated
|
||||
var focusx, focusy float32 |
||||
if form.Focus != "" { |
||||
spl := strings.Split(form.Focus, ",") |
||||
if len(spl) != 2 { |
||||
l.Debugf("improperly formatted focus %s", form.Focus) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) |
||||
return |
||||
} |
||||
xStr := spl[0] |
||||
yStr := spl[1] |
||||
if xStr == "" || yStr == "" { |
||||
l.Debugf("improperly formatted focus %s", form.Focus) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) |
||||
return |
||||
} |
||||
fx, err := strconv.ParseFloat(xStr, 32) |
||||
if err != nil { |
||||
l.Debugf("improperly formatted focus %s: %s", form.Focus, err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) |
||||
return |
||||
} |
||||
if fx > 1 || fx < -1 { |
||||
l.Debugf("improperly formatted focus %s", form.Focus) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) |
||||
return |
||||
} |
||||
focusx = float32(fx) |
||||
fy, err := strconv.ParseFloat(yStr, 32) |
||||
if err != nil { |
||||
l.Debugf("improperly formatted focus %s: %s", form.Focus, err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) |
||||
return |
||||
} |
||||
if fy > 1 || fy < -1 { |
||||
l.Debugf("improperly formatted focus %s", form.Focus) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) |
||||
return |
||||
} |
||||
focusy = float32(fy) |
||||
} |
||||
attachment.FileMeta.Focus.X = focusx |
||||
attachment.FileMeta.Focus.Y = focusy |
||||
|
||||
// prepare the frontend representation now -- if there are any errors here at least we can bail without
|
||||
// having already put something in the database and then having to clean it up again (eugh)
|
||||
mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment) |
||||
if err != nil { |
||||
l.Debugf("error parsing media attachment to frontend type: %s", err) |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)}) |
||||
return |
||||
} |
||||
|
||||
// now we can confidently put the attachment in the database
|
||||
if err := m.db.Put(attachment); err != nil { |
||||
l.Debugf("error storing media attachment in db: %s", err) |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)}) |
||||
return |
||||
} |
||||
|
||||
// and return its frontend representation
|
||||
c.JSON(http.StatusAccepted, mastoAttachment) |
||||
} |
||||
|
||||
func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error { |
||||
// check there actually is a file attached and it's not size 0
|
||||
if form.File == nil || form.File.Size == 0 { |
||||
return errors.New("no attachment given") |
||||
} |
||||
|
||||
// a very superficial check to see if no size limits are exceeded
|
||||
// we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
|
||||
maxSize := config.MaxVideoSize |
||||
if config.MaxImageSize > maxSize { |
||||
maxSize = config.MaxImageSize |
||||
} |
||||
if form.File.Size > int64(maxSize) { |
||||
return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) |
||||
} |
||||
|
||||
if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars { |
||||
return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description)) |
||||
} |
||||
|
||||
// TODO: validate focus here
|
||||
|
||||
return nil |
||||
} |
||||
@ -0,0 +1,194 @@
|
||||
/* |
||||
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 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" |
||||
"github.com/superseriousbusiness/gotosocial/testrig" |
||||
) |
||||
|
||||
type MediaCreateTestSuite struct { |
||||
// standard suite interfaces
|
||||
suite.Suite |
||||
config *config.Config |
||||
db db.DB |
||||
log *logrus.Logger |
||||
storage storage.Storage |
||||
mastoConverter mastotypes.Converter |
||||
mediaHandler media.MediaHandler |
||||
oauthServer oauth.Server |
||||
|
||||
// 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 |
||||
|
||||
// item being tested
|
||||
mediaModule *mediamodule.MediaModule |
||||
} |
||||
|
||||
/* |
||||
TEST INFRASTRUCTURE |
||||
*/ |
||||
|
||||
func (suite *MediaCreateTestSuite) 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) |
||||
|
||||
// setup module being tested
|
||||
suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.MediaModule) |
||||
} |
||||
|
||||
func (suite *MediaCreateTestSuite) TearDownSuite() { |
||||
if err := suite.db.Stop(context.Background()); err != nil { |
||||
logrus.Panicf("error closing db connection: %s", err) |
||||
} |
||||
} |
||||
|
||||
func (suite *MediaCreateTestSuite) 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() |
||||
} |
||||
|
||||
func (suite *MediaCreateTestSuite) TearDownTest() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
/* |
||||
ACTUAL TESTS |
||||
*/ |
||||
|
||||
func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() { |
||||
|
||||
// set up the context for the request
|
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(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) |
||||
} |
||||
|
||||
// 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) |
||||
|
||||
// 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) |
||||
|
||||
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) |
||||
|
||||
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) { |
||||
suite.Run(t, new(MediaCreateTestSuite)) |
||||
} |
||||
@ -0,0 +1,27 @@
|
||||
/* |
||||
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 security |
||||
|
||||
import "github.com/gin-gonic/gin" |
||||
|
||||
// flocBlock prevents google chrome cohort tracking by writing the Permissions-Policy header after all other parts of the request have been completed.
|
||||
// See: https://plausible.io/blog/google-floc
|
||||
func (m *module) flocBlock(c *gin.Context) { |
||||
c.Header("Permissions-Policy", "interest-cohort=()") |
||||
} |
||||
@ -0,0 +1,50 @@
|
||||
/* |
||||
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 security |
||||
|
||||
import ( |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule" |
||||
"github.com/superseriousbusiness/gotosocial/internal/config" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/router" |
||||
) |
||||
|
||||
// module implements the apiclient interface
|
||||
type module struct { |
||||
config *config.Config |
||||
log *logrus.Logger |
||||
} |
||||
|
||||
// New returns a new security module
|
||||
func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { |
||||
return &module{ |
||||
config: config, |
||||
log: log, |
||||
} |
||||
} |
||||
|
||||
func (m *module) Route(s router.Router) error { |
||||
s.AttachMiddleware(m.flocBlock) |
||||
return nil |
||||
} |
||||
|
||||
func (m *module) CreateTables(db db.DB) error { |
||||
return nil |
||||
} |
||||
@ -0,0 +1,138 @@
|
||||
/* |
||||
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" |
||||
"strings" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule" |
||||
"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" |
||||
"github.com/superseriousbusiness/gotosocial/internal/media" |
||||
"github.com/superseriousbusiness/gotosocial/internal/router" |
||||
) |
||||
|
||||
const ( |
||||
IDKey = "id" |
||||
BasePath = "/api/v1/statuses" |
||||
BasePathWithID = BasePath + "/:" + IDKey |
||||
|
||||
ContextPath = BasePathWithID + "/context" |
||||
|
||||
FavouritedPath = BasePathWithID + "/favourited_by" |
||||
FavouritePath = BasePathWithID + "/favourite" |
||||
UnfavouritePath = BasePathWithID + "/unfavourite" |
||||
|
||||
RebloggedPath = BasePathWithID + "/reblogged_by" |
||||
ReblogPath = BasePathWithID + "/reblog" |
||||
UnreblogPath = BasePathWithID + "/unreblog" |
||||
|
||||
BookmarkPath = BasePathWithID + "/bookmark" |
||||
UnbookmarkPath = BasePathWithID + "/unbookmark" |
||||
|
||||
MutePath = BasePathWithID + "/mute" |
||||
UnmutePath = BasePathWithID + "/unmute" |
||||
|
||||
PinPath = BasePathWithID + "/pin" |
||||
UnpinPath = BasePathWithID + "/unpin" |
||||
) |
||||
|
||||
type StatusModule struct { |
||||
config *config.Config |
||||
db db.DB |
||||
mediaHandler media.MediaHandler |
||||
mastoConverter mastotypes.Converter |
||||
distributor distributor.Distributor |
||||
log *logrus.Logger |
||||
} |
||||
|
||||
// New returns a new account module
|
||||
func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule { |
||||
return &StatusModule{ |
||||
config: config, |
||||
db: db, |
||||
mediaHandler: mediaHandler, |
||||
mastoConverter: mastoConverter, |
||||
distributor: distributor, |
||||
log: log, |
||||
} |
||||
} |
||||
|
||||
// Route attaches all routes from this module to the given router
|
||||
func (m *StatusModule) Route(r router.Router) error { |
||||
r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler) |
||||
r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler) |
||||
|
||||
r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler) |
||||
r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler) |
||||
|
||||
r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) |
||||
return nil |
||||
} |
||||
|
||||
func (m *StatusModule) CreateTables(db db.DB) error { |
||||
models := []interface{}{ |
||||
>smodel.User{}, |
||||
>smodel.Account{}, |
||||
>smodel.Block{}, |
||||
>smodel.Follow{}, |
||||
>smodel.FollowRequest{}, |
||||
>smodel.Status{}, |
||||
>smodel.StatusFave{}, |
||||
>smodel.StatusBookmark{}, |
||||
>smodel.StatusMute{}, |
||||
>smodel.StatusPin{}, |
||||
>smodel.Application{}, |
||||
>smodel.EmailDomainBlock{}, |
||||
>smodel.MediaAttachment{}, |
||||
>smodel.Emoji{}, |
||||
>smodel.Tag{}, |
||||
>smodel.Mention{}, |
||||
} |
||||
|
||||
for _, m := range models { |
||||
if err := db.CreateTable(m); err != nil { |
||||
return fmt.Errorf("error creating table: %s", err) |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (m *StatusModule) muxHandler(c *gin.Context) { |
||||
m.log.Debug("entering mux handler") |
||||
ru := c.Request.RequestURI |
||||
|
||||
switch c.Request.Method { |
||||
case http.MethodGet: |
||||
if strings.HasPrefix(ru, ContextPath) { |
||||
// TODO
|
||||
} else if strings.HasPrefix(ru, FavouritedPath) { |
||||
m.StatusFavedByGETHandler(c) |
||||
} else { |
||||
m.StatusGETHandler(c) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,463 @@
|
||||
/* |
||||
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 ( |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
"time" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/google/uuid" |
||||
"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" |
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util" |
||||
) |
||||
|
||||
type advancedStatusCreateForm struct { |
||||
mastotypes.StatusCreateRequest |
||||
advancedVisibilityFlagsForm |
||||
} |
||||
|
||||
type advancedVisibilityFlagsForm struct { |
||||
// The gotosocial visibility model
|
||||
VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"` |
||||
// This status will be federated beyond the local timeline(s)
|
||||
Federated *bool `form:"federated"` |
||||
// This status can be boosted/reblogged
|
||||
Boostable *bool `form:"boostable"` |
||||
// This status can be replied to
|
||||
Replyable *bool `form:"replyable"` |
||||
// This status can be liked/faved
|
||||
Likeable *bool `form:"likeable"` |
||||
} |
||||
|
||||
func (m *StatusModule) StatusCreatePOSTHandler(c *gin.Context) { |
||||
l := m.log.WithField("func", "statusCreatePOSTHandler") |
||||
authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
|
||||
if err != nil { |
||||
l.Debugf("couldn't auth: %s", err) |
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// First check this user/account is permitted to post new statuses.
|
||||
// There's no point continuing otherwise.
|
||||
if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { |
||||
l.Debugf("couldn't auth: %s", err) |
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) |
||||
return |
||||
} |
||||
|
||||
// extract the status create form from the request context
|
||||
l.Tracef("parsing request form: %s", c.Request.Form) |
||||
form := &advancedStatusCreateForm{} |
||||
if err := c.ShouldBind(form); err != nil || form == nil { |
||||
l.Debugf("could not parse form from request: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) |
||||
return |
||||
} |
||||
|
||||
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||
l.Tracef("validating form %+v", form) |
||||
if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil { |
||||
l.Debugf("error validating form: %s", err) |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// At this point we know the account is permitted to post, and we know the request form
|
||||
// is valid (at least according to the API specifications and the instance configuration).
|
||||
// So now we can start digging a bit deeper into the form and building up the new status from it.
|
||||
|
||||
// first we create a new status and add some basic info to it
|
||||
uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host) |
||||
thisStatusID := uuid.NewString() |
||||
thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) |
||||
thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) |
||||
newStatus := >smodel.Status{ |
||||
ID: thisStatusID, |
||||
URI: thisStatusURI, |
||||
URL: thisStatusURL, |
||||
Content: util.HTMLFormat(form.Status), |
||||
CreatedAt: time.Now(), |
||||
UpdatedAt: time.Now(), |
||||
Local: true, |
||||
AccountID: authed.Account.ID, |
||||
ContentWarning: form.SpoilerText, |
||||
ActivityStreamsType: gtsmodel.ActivityStreamsNote, |
||||
Sensitive: form.Sensitive, |
||||
Language: form.Language, |
||||
CreatedWithApplicationID: authed.Application.ID, |
||||
Text: form.Status, |
||||
} |
||||
|
||||
// check if replyToID is ok
|
||||
if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil { |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// check if mediaIDs are ok
|
||||
if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil { |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// check if visibility settings are ok
|
||||
if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil { |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// handle language settings
|
||||
if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil { |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// handle mentions
|
||||
if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil { |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil { |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil { |
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
/* |
||||
FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it |
||||
*/ |
||||
|
||||
// put the new status in the database, generating an ID for it in the process
|
||||
if err := m.db.Put(newStatus); err != nil { |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// change the status ID of the media attachments to the new status
|
||||
for _, a := range newStatus.GTSMediaAttachments { |
||||
a.StatusID = newStatus.ID |
||||
a.UpdatedAt = time.Now() |
||||
if err := m.db.UpdateByID(a.ID, a); 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.ActivityStreamsCreate, |
||||
Activity: newStatus, |
||||
} |
||||
|
||||
// return the frontend representation of the new status to the submitter
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil) |
||||
if err != nil { |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
c.JSON(http.StatusOK, mastoStatus) |
||||
} |
||||
|
||||
func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error { |
||||
// validate that, structurally, we have a valid status/post
|
||||
if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { |
||||
return errors.New("no status, media, or poll provided") |
||||
} |
||||
|
||||
if form.MediaIDs != nil && form.Poll != nil { |
||||
return errors.New("can't post media + poll in same status") |
||||
} |
||||
|
||||
// validate status
|
||||
if form.Status != "" { |
||||
if len(form.Status) > config.MaxChars { |
||||
return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars) |
||||
} |
||||
} |
||||
|
||||
// validate media attachments
|
||||
if len(form.MediaIDs) > config.MaxMediaFiles { |
||||
return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles) |
||||
} |
||||
|
||||
// validate poll
|
||||
if form.Poll != nil { |
||||
if form.Poll.Options == nil { |
||||
return errors.New("poll with no options") |
||||
} |
||||
if len(form.Poll.Options) > config.PollMaxOptions { |
||||
return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions) |
||||
} |
||||
for _, p := range form.Poll.Options { |
||||
if len(p) > config.PollOptionMaxChars { |
||||
return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// validate spoiler text/cw
|
||||
if form.SpoilerText != "" { |
||||
if len(form.SpoilerText) > config.CWMaxChars { |
||||
return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars) |
||||
} |
||||
} |
||||
|
||||
// validate post language
|
||||
if form.Language != "" { |
||||
if err := util.ValidateLanguage(form.Language); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { |
||||
// by default all flags are set to true
|
||||
gtsAdvancedVis := >smodel.VisibilityAdvanced{ |
||||
Federated: true, |
||||
Boostable: true, |
||||
Replyable: true, |
||||
Likeable: true, |
||||
} |
||||
|
||||
var gtsBasicVis gtsmodel.Visibility |
||||
// Advanced takes priority if it's set.
|
||||
// If it's not set, take whatever masto visibility is set.
|
||||
// If *that's* not set either, then just take the account default.
|
||||
// If that's also not set, take the default for the whole instance.
|
||||
if form.VisibilityAdvanced != nil { |
||||
gtsBasicVis = *form.VisibilityAdvanced |
||||
} else if form.Visibility != "" { |
||||
gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility) |
||||
} else if accountDefaultVis != "" { |
||||
gtsBasicVis = accountDefaultVis |
||||
} else { |
||||
gtsBasicVis = gtsmodel.VisibilityDefault |
||||
} |
||||
|
||||
switch gtsBasicVis { |
||||
case gtsmodel.VisibilityPublic: |
||||
// for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
|
||||
break |
||||
case gtsmodel.VisibilityUnlocked: |
||||
// for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
|
||||
if form.Federated != nil { |
||||
gtsAdvancedVis.Federated = *form.Federated |
||||
} |
||||
|
||||
if form.Boostable != nil { |
||||
gtsAdvancedVis.Boostable = *form.Boostable |
||||
} |
||||
|
||||
if form.Replyable != nil { |
||||
gtsAdvancedVis.Replyable = *form.Replyable |
||||
} |
||||
|
||||
if form.Likeable != nil { |
||||
gtsAdvancedVis.Likeable = *form.Likeable |
||||
} |
||||
|
||||
case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: |
||||
// for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
|
||||
gtsAdvancedVis.Boostable = false |
||||
|
||||
if form.Federated != nil { |
||||
gtsAdvancedVis.Federated = *form.Federated |
||||
} |
||||
|
||||
if form.Replyable != nil { |
||||
gtsAdvancedVis.Replyable = *form.Replyable |
||||
} |
||||
|
||||
if form.Likeable != nil { |
||||
gtsAdvancedVis.Likeable = *form.Likeable |
||||
} |
||||
|
||||
case gtsmodel.VisibilityDirect: |
||||
// direct is pretty easy: there's only one possible setting so return it
|
||||
gtsAdvancedVis.Federated = true |
||||
gtsAdvancedVis.Boostable = false |
||||
gtsAdvancedVis.Federated = true |
||||
gtsAdvancedVis.Likeable = true |
||||
} |
||||
|
||||
status.Visibility = gtsBasicVis |
||||
status.VisibilityAdvanced = gtsAdvancedVis |
||||
return nil |
||||
} |
||||
|
||||
func (m *StatusModule) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { |
||||
if form.InReplyToID == "" { |
||||
return nil |
||||
} |
||||
|
||||
// If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
|
||||
//
|
||||
// 1. Does the replied status exist in the database?
|
||||
// 2. Is the replied status marked as replyable?
|
||||
// 3. Does a block exist between either the current account or the account that posted the status it's replying to?
|
||||
//
|
||||
// If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
|
||||
repliedStatus := >smodel.Status{} |
||||
repliedAccount := >smodel.Account{} |
||||
// check replied status exists + is replyable
|
||||
if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); ok { |
||||
return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) |
||||
} else { |
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) |
||||
} |
||||
} |
||||
|
||||
if !repliedStatus.VisibilityAdvanced.Replyable { |
||||
return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) |
||||
} |
||||
|
||||
// check replied account is known to us
|
||||
if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); ok { |
||||
return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) |
||||
} else { |
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) |
||||
} |
||||
} |
||||
// check if a block exists
|
||||
if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) |
||||
} |
||||
} else if blocked { |
||||
return fmt.Errorf("status with id %s not replyable", form.InReplyToID) |
||||
} |
||||
status.InReplyToID = repliedStatus.ID |
||||
status.InReplyToAccountID = repliedAccount.ID |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (m *StatusModule) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { |
||||
if form.MediaIDs == nil { |
||||
return nil |
||||
} |
||||
|
||||
gtsMediaAttachments := []*gtsmodel.MediaAttachment{} |
||||
attachments := []string{} |
||||
for _, mediaID := range form.MediaIDs { |
||||
// check these attachments exist
|
||||
a := >smodel.MediaAttachment{} |
||||
if err := m.db.GetByID(mediaID, a); err != nil { |
||||
return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) |
||||
} |
||||
// check they belong to the requesting account id
|
||||
if a.AccountID != thisAccountID { |
||||
return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) |
||||
} |
||||
// check they're not already used in a status
|
||||
if a.StatusID != "" || a.ScheduledStatusID != "" { |
||||
return fmt.Errorf("media with id %s is already attached to a status", mediaID) |
||||
} |
||||
gtsMediaAttachments = append(gtsMediaAttachments, a) |
||||
attachments = append(attachments, a.ID) |
||||
} |
||||
status.GTSMediaAttachments = gtsMediaAttachments |
||||
status.Attachments = attachments |
||||
return nil |
||||
} |
||||
|
||||
func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { |
||||
if form.Language != "" { |
||||
status.Language = form.Language |
||||
} else { |
||||
status.Language = accountDefaultLanguage |
||||
} |
||||
if status.Language == "" { |
||||
return errors.New("no language given either in status create form or account default") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (m *StatusModule) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { |
||||
menchies := []string{} |
||||
gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID) |
||||
if err != nil { |
||||
return fmt.Errorf("error generating mentions from status: %s", err) |
||||
} |
||||
for _, menchie := range gtsMenchies { |
||||
if err := m.db.Put(menchie); err != nil { |
||||
return fmt.Errorf("error putting mentions in db: %s", err) |
||||
} |
||||
menchies = append(menchies, menchie.TargetAccountID) |
||||
} |
||||
// add full populated gts menchies to the status for passing them around conveniently
|
||||
status.GTSMentions = gtsMenchies |
||||
// add just the ids of the mentioned accounts to the status for putting in the db
|
||||
status.Mentions = menchies |
||||
return nil |
||||
} |
||||
|
||||
func (m *StatusModule) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { |
||||
tags := []string{} |
||||
gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID) |
||||
if err != nil { |
||||
return fmt.Errorf("error generating hashtags from status: %s", err) |
||||
} |
||||
for _, tag := range gtsTags { |
||||
if err := m.db.Upsert(tag, "name"); err != nil { |
||||
return fmt.Errorf("error putting tags in db: %s", err) |
||||
} |
||||
tags = append(tags, tag.ID) |
||||
} |
||||
// add full populated gts tags to the status for passing them around conveniently
|
||||
status.GTSTags = gtsTags |
||||
// add just the ids of the used tags to the status for putting in the db
|
||||
status.Tags = tags |
||||
return nil |
||||
} |
||||
|
||||
func (m *StatusModule) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { |
||||
emojis := []string{} |
||||
gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID) |
||||
if err != nil { |
||||
return fmt.Errorf("error generating emojis from status: %s", err) |
||||
} |
||||
for _, e := range gtsEmojis { |
||||
emojis = append(emojis, e.ID) |
||||
} |
||||
// add full populated gts emojis to the status for passing them around conveniently
|
||||
status.GTSEmojis = gtsEmojis |
||||
// add just the ids of the used emojis to the status for putting in the db
|
||||
status.Emojis = emojis |
||||
return nil |
||||
} |
||||
@ -0,0 +1,106 @@
|
||||
/* |
||||
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" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
) |
||||
|
||||
func (m *StatusModule) StatusDELETEHandler(c *gin.Context) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "StatusDELETEHandler", |
||||
"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 delete 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 |
||||
} |
||||
|
||||
if targetStatus.AccountID != authed.Account.ID { |
||||
l.Debug("status doesn't belong to requesting account") |
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"}) |
||||
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 |
||||
} |
||||
|
||||
var boostOfStatus *gtsmodel.Status |
||||
if targetStatus.BoostOfID != "" { |
||||
boostOfStatus = >smodel.Status{} |
||||
if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { |
||||
l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
} |
||||
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) |
||||
if err != nil { |
||||
l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { |
||||
l.Errorf("error deleting status from the database: %s", err) |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
m.distributor.FromClientAPI() <- distributor.FromClientAPI{ |
||||
APObjectType: gtsmodel.ActivityStreamsNote, |
||||
APActivityType: gtsmodel.ActivityStreamsDelete, |
||||
Activity: targetStatus, |
||||
} |
||||
|
||||
c.JSON(http.StatusOK, mastoStatus) |
||||
} |
||||
@ -0,0 +1,136 @@
|
||||
/* |
||||
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" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
) |
||||
|
||||
func (m *StatusModule) StatusFavePOSTHandler(c *gin.Context) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "StatusFavePOSTHandler", |
||||
"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 fave 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") |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
// is the status faveable?
|
||||
if !targetStatus.VisibilityAdvanced.Likeable { |
||||
l.Debug("status is not faveable") |
||||
c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
// it's visible! it's faveable! so let's fave the FUCK out of it
|
||||
fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID) |
||||
if err != nil { |
||||
l.Debugf("error faveing status: %s", err) |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
var boostOfStatus *gtsmodel.Status |
||||
if targetStatus.BoostOfID != "" { |
||||
boostOfStatus = >smodel.Status{} |
||||
if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { |
||||
l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
} |
||||
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) |
||||
if err != nil { |
||||
l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
// if the targeted status was already faved, faved will be nil
|
||||
// only put the fave in the distributor if something actually changed
|
||||
if fave != nil { |
||||
fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
|
||||
m.distributor.FromClientAPI() <- distributor.FromClientAPI{ |
||||
APObjectType: gtsmodel.ActivityStreamsNote, // status is a note
|
||||
APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note
|
||||
Activity: fave, // pass the fave along for processing
|
||||
} |
||||
} |
||||
|
||||
c.JSON(http.StatusOK, mastoStatus) |
||||
} |
||||
@ -0,0 +1,128 @@
|
||||
/* |
||||
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" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" |
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
) |
||||
|
||||
func (m *StatusModule) StatusFavedByGETHandler(c *gin.Context) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "statusGETHandler", |
||||
"request_uri": c.Request.RequestURI, |
||||
"user_agent": c.Request.UserAgent(), |
||||
"origin_ip": c.ClientIP(), |
||||
}) |
||||
l.Debugf("entering function") |
||||
|
||||
var requestingAccount *gtsmodel.Account |
||||
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 but will continue to serve anyway if public status") |
||||
requestingAccount = nil |
||||
} else { |
||||
requestingAccount = authed.Account |
||||
} |
||||
|
||||
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, requestingAccount, 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") |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
// get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
|
||||
favingAccounts, err := m.db.WhoFavedStatus(targetStatus) |
||||
if err != nil { |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
// filter the list so the user doesn't see accounts they blocked or which blocked them
|
||||
filteredAccounts := []*gtsmodel.Account{} |
||||
for _, acc := range favingAccounts { |
||||
blocked, err := m.db.Blocked(authed.Account.ID, acc.ID) |
||||
if err != nil { |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
if !blocked { |
||||
filteredAccounts = append(filteredAccounts, acc) |
||||
} |
||||
} |
||||
|
||||
// TODO: filter other things here? suspended? muted? silenced?
|
||||
|
||||
// now we can return the masto representation of those accounts
|
||||
mastoAccounts := []*mastotypes.Account{} |
||||
for _, acc := range filteredAccounts { |
||||
mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc) |
||||
if err != nil { |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
mastoAccounts = append(mastoAccounts, mastoAccount) |
||||
} |
||||
|
||||
c.JSON(http.StatusOK, mastoAccounts) |
||||
} |
||||
@ -0,0 +1,111 @@
|
||||
/* |
||||
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" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
) |
||||
|
||||
func (m *StatusModule) StatusGETHandler(c *gin.Context) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "statusGETHandler", |
||||
"request_uri": c.Request.RequestURI, |
||||
"user_agent": c.Request.UserAgent(), |
||||
"origin_ip": c.ClientIP(), |
||||
}) |
||||
l.Debugf("entering function") |
||||
|
||||
var requestingAccount *gtsmodel.Account |
||||
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 but will continue to serve anyway if public status") |
||||
requestingAccount = nil |
||||
} else { |
||||
requestingAccount = authed.Account |
||||
} |
||||
|
||||
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, requestingAccount, 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") |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
var boostOfStatus *gtsmodel.Status |
||||
if targetStatus.BoostOfID != "" { |
||||
boostOfStatus = >smodel.Status{} |
||||
if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { |
||||
l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
} |
||||
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) |
||||
if err != nil { |
||||
l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
c.JSON(http.StatusOK, mastoStatus) |
||||
} |
||||
@ -0,0 +1,136 @@
|
||||
/* |
||||
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" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/sirupsen/logrus" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/distributor" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
) |
||||
|
||||
func (m *StatusModule) StatusUnfavePOSTHandler(c *gin.Context) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "StatusUnfavePOSTHandler", |
||||
"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 unfave 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") |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
// is the status faveable?
|
||||
if !targetStatus.VisibilityAdvanced.Likeable { |
||||
l.Debug("status is not faveable") |
||||
c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
// it's visible! it's faveable! so let's unfave the FUCK out of it
|
||||
fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID) |
||||
if err != nil { |
||||
l.Debugf("error unfaveing status: %s", err) |
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
var boostOfStatus *gtsmodel.Status |
||||
if targetStatus.BoostOfID != "" { |
||||
boostOfStatus = >smodel.Status{} |
||||
if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { |
||||
l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
} |
||||
|
||||
mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) |
||||
if err != nil { |
||||
l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) |
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) |
||||
return |
||||
} |
||||
|
||||
// fave might be nil if this status wasn't faved in the first place
|
||||
// we only want to pass the message to the distributor if something actually changed
|
||||
if fave != nil { |
||||
fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
|
||||
m.distributor.FromClientAPI() <- distributor.FromClientAPI{ |
||||
APObjectType: gtsmodel.ActivityStreamsNote, // status is a note
|
||||
APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave
|
||||
Activity: fave, // pass the undone fave along
|
||||
} |
||||
} |
||||
|
||||
c.JSON(http.StatusOK, mastoStatus) |
||||
} |
||||
@ -0,0 +1,346 @@
|
||||
/* |
||||
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 ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"io/ioutil" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"net/url" |
||||
"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 StatusCreateTestSuite struct { |
||||
// standard suite interfaces
|
||||
suite.Suite |
||||
config *config.Config |
||||
db db.DB |
||||
log *logrus.Logger |
||||
storage storage.Storage |
||||
mastoConverter mastotypes.Converter |
||||
mediaHandler media.MediaHandler |
||||
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 |
||||
|
||||
// module being tested
|
||||
statusModule *status.StatusModule |
||||
} |
||||
|
||||
/* |
||||
TEST INFRASTRUCTURE |
||||
*/ |
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusCreateTestSuite) 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.StatusModule) |
||||
} |
||||
|
||||
func (suite *StatusCreateTestSuite) TearDownSuite() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
func (suite *StatusCreateTestSuite) 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() |
||||
} |
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
func (suite *StatusCreateTestSuite) TearDownTest() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
} |
||||
|
||||
/* |
||||
ACTUAL TESTS |
||||
*/ |
||||
|
||||
/* |
||||
TESTING: StatusCreatePOSTHandler |
||||
*/ |
||||
|
||||
// Post a new status with some custom visibility settings
|
||||
func (suite *StatusCreateTestSuite) TestPostNewStatus() { |
||||
|
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
// 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", status.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = url.Values{ |
||||
"status": {"this is a brand new status! #helloworld"}, |
||||
"spoiler_text": {"hello hello"}, |
||||
"sensitive": {"true"}, |
||||
"visibility_advanced": {"mutuals_only"}, |
||||
"likeable": {"false"}, |
||||
"replyable": {"false"}, |
||||
"federated": {"false"}, |
||||
} |
||||
suite.statusModule.StatusCreatePOSTHandler(ctx) |
||||
|
||||
// check response
|
||||
|
||||
// 1. we should have OK from our call to the function
|
||||
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 := &mastomodel.Status{} |
||||
err = json.Unmarshal(b, statusReply) |
||||
assert.NoError(suite.T(), err) |
||||
|
||||
assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText) |
||||
assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content) |
||||
assert.True(suite.T(), statusReply.Sensitive) |
||||
assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) |
||||
assert.Len(suite.T(), statusReply.Tags, 1) |
||||
assert.Equal(suite.T(), mastomodel.Tag{ |
||||
Name: "helloworld", |
||||
URL: "http://localhost:8080/tags/helloworld", |
||||
}, statusReply.Tags[0]) |
||||
|
||||
gtsTag := >smodel.Tag{} |
||||
err = suite.db.GetWhere("name", "helloworld", gtsTag) |
||||
assert.NoError(suite.T(), err) |
||||
assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) |
||||
} |
||||
|
||||
func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { |
||||
|
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
// 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", status.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = url.Values{ |
||||
"status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "}, |
||||
} |
||||
suite.statusModule.StatusCreatePOSTHandler(ctx) |
||||
|
||||
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 := &mastomodel.Status{} |
||||
err = json.Unmarshal(b, statusReply) |
||||
assert.NoError(suite.T(), err) |
||||
|
||||
assert.Equal(suite.T(), "", statusReply.SpoilerText) |
||||
assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content) |
||||
|
||||
assert.Len(suite.T(), statusReply.Emojis, 1) |
||||
mastoEmoji := statusReply.Emojis[0] |
||||
gtsEmoji := testrig.NewTestEmojis()["rainbow"] |
||||
|
||||
assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode) |
||||
assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL) |
||||
assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL) |
||||
} |
||||
|
||||
// Try to reply to a status that doesn't exist
|
||||
func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { |
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
// 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", status.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = url.Values{ |
||||
"status": {"this is a reply to a status that doesn't exist"}, |
||||
"spoiler_text": {"don't open cuz it won't work"}, |
||||
"in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"}, |
||||
} |
||||
suite.statusModule.StatusCreatePOSTHandler(ctx) |
||||
|
||||
// check response
|
||||
|
||||
suite.EqualValues(http.StatusBadRequest, 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":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b)) |
||||
} |
||||
|
||||
// Post a reply to the status of a local user that allows replies.
|
||||
func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() { |
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
// 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", status.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = url.Values{ |
||||
"status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)}, |
||||
"in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID}, |
||||
} |
||||
suite.statusModule.StatusCreatePOSTHandler(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 := &mastomodel.Status{} |
||||
err = json.Unmarshal(b, statusReply) |
||||
assert.NoError(suite.T(), err) |
||||
|
||||
assert.Equal(suite.T(), "", statusReply.SpoilerText) |
||||
assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content) |
||||
assert.False(suite.T(), statusReply.Sensitive) |
||||
assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) |
||||
assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID) |
||||
assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID) |
||||
assert.Len(suite.T(), statusReply.Mentions, 1) |
||||
} |
||||
|
||||
// Take a media file which is currently not associated with a status, and attach it to a new status.
|
||||
func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() { |
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
// 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", status.BasePath), nil) // the endpoint we're hitting
|
||||
ctx.Request.Form = url.Values{ |
||||
"status": {"here's an image attachment"}, |
||||
"media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"}, |
||||
} |
||||
suite.statusModule.StatusCreatePOSTHandler(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.Equal(suite.T(), "", statusReply.SpoilerText) |
||||
assert.Equal(suite.T(), "here's an image attachment", statusReply.Content) |
||||
assert.False(suite.T(), statusReply.Sensitive) |
||||
assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) |
||||
|
||||
// there should be one media attachment
|
||||
assert.Len(suite.T(), statusReply.MediaAttachments, 1) |
||||
|
||||
// get the updated media attachment from the database
|
||||
gtsAttachment := >smodel.MediaAttachment{} |
||||
err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment) |
||||
assert.NoError(suite.T(), err) |
||||
|
||||
// convert it to a masto attachment
|
||||
gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment) |
||||
assert.NoError(suite.T(), err) |
||||
|
||||
// compare it with what we have now
|
||||
assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto) |
||||
|
||||
// the status id of the attachment should now be set to the id of the status we just created
|
||||
assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID) |
||||
} |
||||
|
||||
func TestStatusCreateTestSuite(t *testing.T) { |
||||
suite.Run(t, new(StatusCreateTestSuite)) |
||||
} |
||||
@ -0,0 +1,207 @@
|
||||
/* |
||||
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 ( |
||||
"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 StatusFaveTestSuite struct { |
||||
// standard suite interfaces
|
||||
suite.Suite |
||||
config *config.Config |
||||
db db.DB |
||||
log *logrus.Logger |
||||
storage storage.Storage |
||||
mastoConverter mastotypes.Converter |
||||
mediaHandler media.MediaHandler |
||||
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.StatusModule |
||||
} |
||||
|
||||
/* |
||||
TEST INFRASTRUCTURE |
||||
*/ |
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusFaveTestSuite) 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.StatusModule) |
||||
} |
||||
|
||||
func (suite *StatusFaveTestSuite) TearDownSuite() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
func (suite *StatusFaveTestSuite) 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 *StatusFaveTestSuite) TearDownTest() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
/* |
||||
ACTUAL TESTS |
||||
*/ |
||||
|
||||
// fave a status
|
||||
func (suite *StatusFaveTestSuite) TestPostFave() { |
||||
|
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_2"] |
||||
|
||||
// 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.FavouritePath, ":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.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 := &mastomodel.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(), mastomodel.VisibilityPublic, statusReply.Visibility) |
||||
assert.True(suite.T(), statusReply.Favourited) |
||||
assert.Equal(suite.T(), 1, statusReply.FavouritesCount) |
||||
} |
||||
|
||||
// try to fave a status that's not faveable
|
||||
func (suite *StatusFaveTestSuite) TestPostUnfaveable() { |
||||
|
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable
|
||||
|
||||
// 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.FavouritePath, ":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.StatusFavePOSTHandler(ctx) |
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable 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 faveable"}`, targetStatus.ID), string(b)) |
||||
} |
||||
|
||||
func TestStatusFaveTestSuite(t *testing.T) { |
||||
suite.Run(t, new(StatusFaveTestSuite)) |
||||
} |
||||
@ -0,0 +1,159 @@
|
||||
/* |
||||
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 ( |
||||
"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 StatusFavedByTestSuite struct { |
||||
// standard suite interfaces
|
||||
suite.Suite |
||||
config *config.Config |
||||
db db.DB |
||||
log *logrus.Logger |
||||
storage storage.Storage |
||||
mastoConverter mastotypes.Converter |
||||
mediaHandler media.MediaHandler |
||||
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.StatusModule |
||||
} |
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusFavedByTestSuite) 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.StatusModule) |
||||
} |
||||
|
||||
func (suite *StatusFavedByTestSuite) TearDownSuite() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
func (suite *StatusFavedByTestSuite) 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 *StatusFavedByTestSuite) TearDownTest() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
/* |
||||
ACTUAL TESTS |
||||
*/ |
||||
|
||||
func (suite *StatusFavedByTestSuite) TestGetFavedBy() { |
||||
t := suite.testTokens["local_account_2"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder() |
||||
ctx, _ := gin.CreateTestContext(recorder) |
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"]) |
||||
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.FavouritedPath, ":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.StatusFavedByGETHandler(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) |
||||
|
||||
accts := []mastomodel.Account{} |
||||
err = json.Unmarshal(b, &accts) |
||||
assert.NoError(suite.T(), err) |
||||
|
||||
assert.Len(suite.T(), accts, 1) |
||||
assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username) |
||||
} |
||||
|
||||
func TestStatusFavedByTestSuite(t *testing.T) { |
||||
suite.Run(t, new(StatusFavedByTestSuite)) |
||||
} |
||||
@ -0,0 +1,168 @@
|
||||
/* |
||||
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 ( |
||||
"testing" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
"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" |
||||
"github.com/superseriousbusiness/gotosocial/internal/media" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
"github.com/superseriousbusiness/gotosocial/internal/storage" |
||||
"github.com/superseriousbusiness/gotosocial/testrig" |
||||
) |
||||
|
||||
type StatusGetTestSuite struct { |
||||
// standard suite interfaces
|
||||
suite.Suite |
||||
config *config.Config |
||||
db db.DB |
||||
log *logrus.Logger |
||||
storage storage.Storage |
||||
mastoConverter mastotypes.Converter |
||||
mediaHandler media.MediaHandler |
||||
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 |
||||
|
||||
// module being tested
|
||||
statusModule *status.StatusModule |
||||
} |
||||
|
||||
/* |
||||
TEST INFRASTRUCTURE |
||||
*/ |
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusGetTestSuite) 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.StatusModule) |
||||
} |
||||
|
||||
func (suite *StatusGetTestSuite) TearDownSuite() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
func (suite *StatusGetTestSuite) 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() |
||||
} |
||||
|
||||
// TearDownTest drops tables to make sure there's no data in the db
|
||||
func (suite *StatusGetTestSuite) TearDownTest() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
} |
||||
|
||||
/* |
||||
ACTUAL TESTS |
||||
*/ |
||||
|
||||
/* |
||||
TESTING: StatusGetPOSTHandler |
||||
*/ |
||||
|
||||
// Post a new status with some custom visibility settings
|
||||
func (suite *StatusGetTestSuite) TestPostNewStatus() { |
||||
|
||||
// t := suite.testTokens["local_account_1"]
|
||||
// oauthToken := oauth.PGTokenToOauthToken(t)
|
||||
|
||||
// // 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", basePath), nil) // the endpoint we're hitting
|
||||
// ctx.Request.Form = url.Values{
|
||||
// "status": {"this is a brand new status! #helloworld"},
|
||||
// "spoiler_text": {"hello hello"},
|
||||
// "sensitive": {"true"},
|
||||
// "visibility_advanced": {"mutuals_only"},
|
||||
// "likeable": {"false"},
|
||||
// "replyable": {"false"},
|
||||
// "federated": {"false"},
|
||||
// }
|
||||
// suite.statusModule.statusGETHandler(ctx)
|
||||
|
||||
// // check response
|
||||
|
||||
// // 1. we should have OK from our call to the function
|
||||
// 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 := &mastomodel.Status{}
|
||||
// err = json.Unmarshal(b, statusReply)
|
||||
// assert.NoError(suite.T(), err)
|
||||
|
||||
// assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
|
||||
// assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
|
||||
// assert.True(suite.T(), statusReply.Sensitive)
|
||||
// assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
|
||||
// assert.Len(suite.T(), statusReply.Tags, 1)
|
||||
// assert.Equal(suite.T(), mastomodel.Tag{
|
||||
// Name: "helloworld",
|
||||
// URL: "http://localhost:8080/tags/helloworld",
|
||||
// }, statusReply.Tags[0])
|
||||
|
||||
// gtsTag := >smodel.Tag{}
|
||||
// err = suite.db.GetWhere("name", "helloworld", gtsTag)
|
||||
// assert.NoError(suite.T(), err)
|
||||
// assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
|
||||
} |
||||
|
||||
func TestStatusGetTestSuite(t *testing.T) { |
||||
suite.Run(t, new(StatusGetTestSuite)) |
||||
} |
||||
@ -0,0 +1,219 @@
|
||||
/* |
||||
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 ( |
||||
"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 StatusUnfaveTestSuite struct { |
||||
// standard suite interfaces
|
||||
suite.Suite |
||||
config *config.Config |
||||
db db.DB |
||||
log *logrus.Logger |
||||
storage storage.Storage |
||||
mastoConverter mastotypes.Converter |
||||
mediaHandler media.MediaHandler |
||||
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.StatusModule |
||||
} |
||||
|
||||
/* |
||||
TEST INFRASTRUCTURE |
||||
*/ |
||||
|
||||
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||
func (suite *StatusUnfaveTestSuite) 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.StatusModule) |
||||
} |
||||
|
||||
func (suite *StatusUnfaveTestSuite) TearDownSuite() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
func (suite *StatusUnfaveTestSuite) 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 *StatusUnfaveTestSuite) TearDownTest() { |
||||
testrig.StandardDBTeardown(suite.db) |
||||
testrig.StandardStorageTeardown(suite.storage) |
||||
} |
||||
|
||||
/* |
||||
ACTUAL TESTS |
||||
*/ |
||||
|
||||
// unfave a status
|
||||
func (suite *StatusUnfaveTestSuite) TestPostUnfave() { |
||||
|
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
// this is the status we wanna unfave: in the testrig it's already faved by this account
|
||||
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.UnfavouritePath, ":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.StatusUnfavePOSTHandler(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 := &mastomodel.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.False(suite.T(), statusReply.Sensitive) |
||||
assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) |
||||
assert.False(suite.T(), statusReply.Favourited) |
||||
assert.Equal(suite.T(), 0, statusReply.FavouritesCount) |
||||
} |
||||
|
||||
// try to unfave a status that's already not faved
|
||||
func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() { |
||||
|
||||
t := suite.testTokens["local_account_1"] |
||||
oauthToken := oauth.PGTokenToOauthToken(t) |
||||
|
||||
// this is the status we wanna unfave: in the testrig it's not faved by this account
|
||||
targetStatus := suite.testStatuses["admin_account_status_2"] |
||||
|
||||
// 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.UnfavouritePath, ":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.StatusUnfavePOSTHandler(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 := &mastomodel.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(), mastomodel.VisibilityPublic, statusReply.Visibility) |
||||
assert.False(suite.T(), statusReply.Favourited) |
||||
assert.Equal(suite.T(), 0, statusReply.FavouritesCount) |
||||
} |
||||
|
||||
func TestStatusUnfaveTestSuite(t *testing.T) { |
||||
suite.Run(t, new(StatusUnfaveTestSuite)) |
||||
} |
||||
@ -0,0 +1,177 @@
|
||||
package config |
||||
|
||||
// TestDefault returns a default config for testing
|
||||
func TestDefault() *Config { |
||||
defaults := GetTestDefaults() |
||||
return &Config{ |
||||
LogLevel: defaults.LogLevel, |
||||
ApplicationName: defaults.ApplicationName, |
||||
Host: defaults.Host, |
||||
Protocol: defaults.Protocol, |
||||
DBConfig: &DBConfig{ |
||||
Type: defaults.DbType, |
||||
Address: defaults.DbAddress, |
||||
Port: defaults.DbPort, |
||||
User: defaults.DbUser, |
||||
Password: defaults.DbPassword, |
||||
Database: defaults.DbDatabase, |
||||
ApplicationName: defaults.ApplicationName, |
||||
}, |
||||
TemplateConfig: &TemplateConfig{ |
||||
BaseDir: defaults.TemplateBaseDir, |
||||
}, |
||||
AccountsConfig: &AccountsConfig{ |
||||
OpenRegistration: defaults.AccountsOpenRegistration, |
||||
RequireApproval: defaults.AccountsRequireApproval, |
||||
ReasonRequired: defaults.AccountsReasonRequired, |
||||
}, |
||||
MediaConfig: &MediaConfig{ |
||||
MaxImageSize: defaults.MediaMaxImageSize, |
||||
MaxVideoSize: defaults.MediaMaxVideoSize, |
||||
MinDescriptionChars: defaults.MediaMinDescriptionChars, |
||||
MaxDescriptionChars: defaults.MediaMaxDescriptionChars, |
||||
}, |
||||
StorageConfig: &StorageConfig{ |
||||
Backend: defaults.StorageBackend, |
||||
BasePath: defaults.StorageBasePath, |
||||
ServeProtocol: defaults.StorageServeProtocol, |
||||
ServeHost: defaults.StorageServeHost, |
||||
ServeBasePath: defaults.StorageServeBasePath, |
||||
}, |
||||
StatusesConfig: &StatusesConfig{ |
||||
MaxChars: defaults.StatusesMaxChars, |
||||
CWMaxChars: defaults.StatusesCWMaxChars, |
||||
PollMaxOptions: defaults.StatusesPollMaxOptions, |
||||
PollOptionMaxChars: defaults.StatusesPollOptionMaxChars, |
||||
MaxMediaFiles: defaults.StatusesMaxMediaFiles, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
// Default returns a config with all default values set
|
||||
func Default() *Config { |
||||
defaults := GetDefaults() |
||||
return &Config{ |
||||
LogLevel: defaults.LogLevel, |
||||
ApplicationName: defaults.ApplicationName, |
||||
Host: defaults.Host, |
||||
Protocol: defaults.Protocol, |
||||
DBConfig: &DBConfig{ |
||||
Type: defaults.DbType, |
||||
Address: defaults.DbAddress, |
||||
Port: defaults.DbPort, |
||||
User: defaults.DbUser, |
||||
Password: defaults.DbPassword, |
||||
Database: defaults.DbDatabase, |
||||
ApplicationName: defaults.ApplicationName, |
||||
}, |
||||
TemplateConfig: &TemplateConfig{ |
||||
BaseDir: defaults.TemplateBaseDir, |
||||
}, |
||||
AccountsConfig: &AccountsConfig{ |
||||
OpenRegistration: defaults.AccountsOpenRegistration, |
||||
RequireApproval: defaults.AccountsRequireApproval, |
||||
ReasonRequired: defaults.AccountsReasonRequired, |
||||
}, |
||||
MediaConfig: &MediaConfig{ |
||||
MaxImageSize: defaults.MediaMaxImageSize, |
||||
MaxVideoSize: defaults.MediaMaxVideoSize, |
||||
MinDescriptionChars: defaults.MediaMinDescriptionChars, |
||||
MaxDescriptionChars: defaults.MediaMaxDescriptionChars, |
||||
}, |
||||
StorageConfig: &StorageConfig{ |
||||
Backend: defaults.StorageBackend, |
||||
BasePath: defaults.StorageBasePath, |
||||
ServeProtocol: defaults.StorageServeProtocol, |
||||
ServeHost: defaults.StorageServeHost, |
||||
ServeBasePath: defaults.StorageServeBasePath, |
||||
}, |
||||
StatusesConfig: &StatusesConfig{ |
||||
MaxChars: defaults.StatusesMaxChars, |
||||
CWMaxChars: defaults.StatusesCWMaxChars, |
||||
PollMaxOptions: defaults.StatusesPollMaxOptions, |
||||
PollOptionMaxChars: defaults.StatusesPollOptionMaxChars, |
||||
MaxMediaFiles: defaults.StatusesMaxMediaFiles, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func GetDefaults() Defaults { |
||||
return Defaults{ |
||||
LogLevel: "info", |
||||
ApplicationName: "gotosocial", |
||||
ConfigPath: "", |
||||
Host: "", |
||||
Protocol: "https", |
||||
|
||||
DbType: "postgres", |
||||
DbAddress: "localhost", |
||||
DbPort: 5432, |
||||
DbUser: "postgres", |
||||
DbPassword: "postgres", |
||||
DbDatabase: "postgres", |
||||
|
||||
TemplateBaseDir: "./web/template/", |
||||
|
||||
AccountsOpenRegistration: true, |
||||
AccountsRequireApproval: true, |
||||
AccountsReasonRequired: true, |
||||
|
||||
MediaMaxImageSize: 2097152, //2mb
|
||||
MediaMaxVideoSize: 10485760, //10mb
|
||||
MediaMinDescriptionChars: 0, |
||||
MediaMaxDescriptionChars: 500, |
||||
|
||||
StorageBackend: "local", |
||||
StorageBasePath: "/gotosocial/storage", |
||||
StorageServeProtocol: "https", |
||||
StorageServeHost: "localhost", |
||||
StorageServeBasePath: "/fileserver", |
||||
|
||||
StatusesMaxChars: 5000, |
||||
StatusesCWMaxChars: 100, |
||||
StatusesPollMaxOptions: 6, |
||||
StatusesPollOptionMaxChars: 50, |
||||
StatusesMaxMediaFiles: 6, |
||||
} |
||||
} |
||||
|
||||
func GetTestDefaults() Defaults { |
||||
return Defaults{ |
||||
LogLevel: "trace", |
||||
ApplicationName: "gotosocial", |
||||
ConfigPath: "", |
||||
Host: "localhost:8080", |
||||
Protocol: "http", |
||||
|
||||
DbType: "postgres", |
||||
DbAddress: "localhost", |
||||
DbPort: 5432, |
||||
DbUser: "postgres", |
||||
DbPassword: "postgres", |
||||
DbDatabase: "postgres", |
||||
|
||||
TemplateBaseDir: "./web/template/", |
||||
|
||||
AccountsOpenRegistration: true, |
||||
AccountsRequireApproval: true, |
||||
AccountsReasonRequired: true, |
||||
|
||||
MediaMaxImageSize: 1048576, //1mb
|
||||
MediaMaxVideoSize: 5242880, //5mb
|
||||
MediaMinDescriptionChars: 0, |
||||
MediaMaxDescriptionChars: 500, |
||||
|
||||
StorageBackend: "local", |
||||
StorageBasePath: "/gotosocial/storage", |
||||
StorageServeProtocol: "http", |
||||
StorageServeHost: "localhost:8080", |
||||
StorageServeBasePath: "/fileserver", |
||||
|
||||
StatusesMaxChars: 5000, |
||||
StatusesCWMaxChars: 100, |
||||
StatusesPollMaxOptions: 6, |
||||
StatusesPollOptionMaxChars: 50, |
||||
StatusesMaxMediaFiles: 6, |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@
|
||||
/* |
||||
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 config |
||||
|
||||
// StatusesConfig pertains to posting/deleting/interacting with statuses
|
||||
type StatusesConfig struct { |
||||
// Maximum amount of characters allowed in a status, excluding CW
|
||||
MaxChars int `yaml:"max_chars"` |
||||
// Maximum amount of characters allowed in a content-warning/spoiler field
|
||||
CWMaxChars int `yaml:"cw_max_chars"` |
||||
// Maximum number of options allowed in a poll
|
||||
PollMaxOptions int `yaml:"poll_max_options"` |
||||
// Maximum characters allowed per poll option
|
||||
PollOptionMaxChars int `yaml:"poll_option_max_chars"` |
||||
// Maximum amount of media files allowed to be attached to one status
|
||||
MaxMediaFiles int `yaml:"max_media_files"` |
||||
} |
||||
@ -0,0 +1,127 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
// ActivityStreamsObject refers to https://www.w3.org/TR/activitystreams-vocabulary/#object-types
|
||||
type ActivityStreamsObject string |
||||
|
||||
const ( |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article
|
||||
ActivityStreamsArticle ActivityStreamsObject = "Article" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio
|
||||
ActivityStreamsAudio ActivityStreamsObject = "Audio" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document
|
||||
ActivityStreamsDocument ActivityStreamsObject = "Event" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event
|
||||
ActivityStreamsEvent ActivityStreamsObject = "Event" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image
|
||||
ActivityStreamsImage ActivityStreamsObject = "Image" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note
|
||||
ActivityStreamsNote ActivityStreamsObject = "Note" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page
|
||||
ActivityStreamsPage ActivityStreamsObject = "Page" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place
|
||||
ActivityStreamsPlace ActivityStreamsObject = "Place" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile
|
||||
ActivityStreamsProfile ActivityStreamsObject = "Profile" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship
|
||||
ActivityStreamsRelationship ActivityStreamsObject = "Relationship" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
|
||||
ActivityStreamsTombstone ActivityStreamsObject = "Tombstone" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video
|
||||
ActivityStreamsVideo ActivityStreamsObject = "Video" |
||||
) |
||||
|
||||
// ActivityStreamsActor refers to https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
|
||||
type ActivityStreamsActor string |
||||
|
||||
const ( |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-application
|
||||
ActivityStreamsApplication ActivityStreamsActor = "Application" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-group
|
||||
ActivityStreamsGroup ActivityStreamsActor = "Group" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-organization
|
||||
ActivityStreamsOrganization ActivityStreamsActor = "Organization" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person
|
||||
ActivityStreamsPerson ActivityStreamsActor = "Person" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-service
|
||||
ActivityStreamsService ActivityStreamsActor = "Service" |
||||
) |
||||
|
||||
// ActivityStreamsActivity refers to https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
|
||||
type ActivityStreamsActivity string |
||||
|
||||
const ( |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept
|
||||
ActivityStreamsAccept ActivityStreamsActivity = "Accept" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-add
|
||||
ActivityStreamsAdd ActivityStreamsActivity = "Add" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce
|
||||
ActivityStreamsAnnounce ActivityStreamsActivity = "Announce" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-arrive
|
||||
ActivityStreamsArrive ActivityStreamsActivity = "Arrive" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-block
|
||||
ActivityStreamsBlock ActivityStreamsActivity = "Block" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-create
|
||||
ActivityStreamsCreate ActivityStreamsActivity = "Create" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete
|
||||
ActivityStreamsDelete ActivityStreamsActivity = "Delete" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-dislike
|
||||
ActivityStreamsDislike ActivityStreamsActivity = "Dislike" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-flag
|
||||
ActivityStreamsFlag ActivityStreamsActivity = "Flag" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow
|
||||
ActivityStreamsFollow ActivityStreamsActivity = "Follow" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-ignore
|
||||
ActivityStreamsIgnore ActivityStreamsActivity = "Ignore" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-invite
|
||||
ActivityStreamsInvite ActivityStreamsActivity = "Invite" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-join
|
||||
ActivityStreamsJoin ActivityStreamsActivity = "Join" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-leave
|
||||
ActivityStreamsLeave ActivityStreamsActivity = "Leave" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like
|
||||
ActivityStreamsLike ActivityStreamsActivity = "Like" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-listen
|
||||
ActivityStreamsListen ActivityStreamsActivity = "Listen" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move
|
||||
ActivityStreamsMove ActivityStreamsActivity = "Move" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-offer
|
||||
ActivityStreamsOffer ActivityStreamsActivity = "Offer" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-question
|
||||
ActivityStreamsQuestion ActivityStreamsActivity = "Question" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-reject
|
||||
ActivityStreamsReject ActivityStreamsActivity = "Reject" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-read
|
||||
ActivityStreamsRead ActivityStreamsActivity = "Read" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-remove
|
||||
ActivityStreamsRemove ActivityStreamsActivity = "Remove" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativereject
|
||||
ActivityStreamsTentativeReject ActivityStreamsActivity = "TentativeReject" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativeaccept
|
||||
ActivityStreamsTentativeAccept ActivityStreamsActivity = "TentativeAccept" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-travel
|
||||
ActivityStreamsTravel ActivityStreamsActivity = "Travel" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-undo
|
||||
ActivityStreamsUndo ActivityStreamsActivity = "Undo" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-update
|
||||
ActivityStreamsUpdate ActivityStreamsActivity = "Update" |
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-view
|
||||
ActivityStreamsView ActivityStreamsActivity = "View" |
||||
) |
||||
@ -0,0 +1,19 @@
|
||||
package gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
// Block refers to the blocking of one account by another.
|
||||
type Block struct { |
||||
// id of this block in the database
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` |
||||
// When was this block created
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// When was this block updated
|
||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// Who created this block?
|
||||
AccountID string `pg:",notnull"` |
||||
// Who is targeted by this block?
|
||||
TargetAccountID string `pg:",notnull"` |
||||
// Activitypub URI for this block
|
||||
URI string |
||||
} |
||||
@ -0,0 +1,74 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
type Emoji struct { |
||||
// database ID of this emoji
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` |
||||
// String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_
|
||||
// eg., 'blob_hug' 'purple_heart' Must be unique with domain.
|
||||
Shortcode string `pg:",notnull,unique:shortcodedomain"` |
||||
// Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
|
||||
Domain string `pg:",notnull,default:'',use_zero,unique:shortcodedomain"` |
||||
// When was this emoji created. Must be unique with shortcode.
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// When was this emoji updated
|
||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// Where can this emoji be retrieved remotely? Null for local emojis.
|
||||
// For remote emojis, it'll be something like:
|
||||
// https://hackers.town/system/custom_emojis/images/000/049/842/original/1b74481204feabfd.png
|
||||
ImageRemoteURL string |
||||
// Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
|
||||
// For remote emojis, it'll be something like:
|
||||
// https://hackers.town/system/custom_emojis/images/000/049/842/static/1b74481204feabfd.png
|
||||
ImageStaticRemoteURL string |
||||
// Where can this emoji be retrieved from the local server? Null for remote emojis.
|
||||
// Assuming our server is hosted at 'example.org', this will be something like:
|
||||
// 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
|
||||
ImageURL string |
||||
// Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
|
||||
// Assuming our server is hosted at 'example.org', this will be something like:
|
||||
// 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
|
||||
ImageStaticURL string |
||||
// Path of the emoji image in the server storage system. Will be something like:
|
||||
// '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
|
||||
ImagePath string `pg:",notnull"` |
||||
// Path of a static version of the emoji image in the server storage system. Will be something like:
|
||||
// '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
|
||||
ImageStaticPath string `pg:",notnull"` |
||||
// MIME content type of the emoji image
|
||||
// Probably "image/png"
|
||||
ImageContentType string `pg:",notnull"` |
||||
// Size of the emoji image file in bytes, for serving purposes.
|
||||
ImageFileSize int `pg:",notnull"` |
||||
// Size of the static version of the emoji image file in bytes, for serving purposes.
|
||||
ImageStaticFileSize int `pg:",notnull"` |
||||
// When was the emoji image last updated?
|
||||
ImageUpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// Has a moderation action disabled this emoji from being shown?
|
||||
Disabled bool `pg:",notnull,default:false"` |
||||
// ActivityStreams uri of this emoji. Something like 'https://example.org/emojis/1234'
|
||||
URI string `pg:",notnull,unique"` |
||||
// Is this emoji visible in the admin emoji picker?
|
||||
VisibleInPicker bool `pg:",notnull,default:true"` |
||||
// In which emoji category is this emoji visible?
|
||||
CategoryID string |
||||
} |
||||
@ -0,0 +1,39 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
// Mention refers to the 'tagging' or 'mention' of a user within a status.
|
||||
type Mention struct { |
||||
// ID of this mention in the database
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` |
||||
// ID of the status this mention originates from
|
||||
StatusID string `pg:",notnull"` |
||||
// When was this mention created?
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// When was this mention last updated?
|
||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// Who created this mention?
|
||||
OriginAccountID string `pg:",notnull"` |
||||
// Who does this mention target?
|
||||
TargetAccountID string `pg:",notnull"` |
||||
// Prevent this mention from generating a notification?
|
||||
Silent bool |
||||
} |
||||
@ -0,0 +1,19 @@
|
||||
/* |
||||
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 gtsmodel |
||||
@ -0,0 +1,138 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
// Status represents a user-created 'post' or 'status' in the database, either remote or local
|
||||
type Status struct { |
||||
// id of the status in the database
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` |
||||
// uri at which this status is reachable
|
||||
URI string `pg:",unique"` |
||||
// web url for viewing this status
|
||||
URL string `pg:",unique"` |
||||
// the html-formatted content of this status
|
||||
Content string |
||||
// Database IDs of any media attachments associated with this status
|
||||
Attachments []string `pg:",array"` |
||||
// Database IDs of any tags used in this status
|
||||
Tags []string `pg:",array"` |
||||
// Database IDs of any accounts mentioned in this status
|
||||
Mentions []string `pg:",array"` |
||||
// Database IDs of any emojis used in this status
|
||||
Emojis []string `pg:",array"` |
||||
// when was this status created?
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// when was this status updated?
|
||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// is this status from a local account?
|
||||
Local bool |
||||
// which account posted this status?
|
||||
AccountID string |
||||
// id of the status this status is a reply to
|
||||
InReplyToID string |
||||
// id of the account that this status replies to
|
||||
InReplyToAccountID string |
||||
// id of the status this status is a boost of
|
||||
BoostOfID string |
||||
// cw string for this status
|
||||
ContentWarning string |
||||
// visibility entry for this status
|
||||
Visibility Visibility `pg:",notnull"` |
||||
// mark the status as sensitive?
|
||||
Sensitive bool |
||||
// what language is this status written in?
|
||||
Language string |
||||
// Which application was used to create this status?
|
||||
CreatedWithApplicationID string |
||||
// advanced visibility for this status
|
||||
VisibilityAdvanced *VisibilityAdvanced |
||||
// What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types
|
||||
// Will probably almost always be Note but who knows!.
|
||||
ActivityStreamsType ActivityStreamsObject |
||||
// Original text of the status without formatting
|
||||
Text string |
||||
|
||||
/* |
||||
NON-DATABASE FIELDS |
||||
|
||||
These are for convenience while passing the status around internally, |
||||
but these fields should *never* be put in the db. |
||||
*/ |
||||
|
||||
// Mentions created in this status
|
||||
GTSMentions []*Mention `pg:"-"` |
||||
// Hashtags used in this status
|
||||
GTSTags []*Tag `pg:"-"` |
||||
// Emojis used in this status
|
||||
GTSEmojis []*Emoji `pg:"-"` |
||||
// MediaAttachments used in this status
|
||||
GTSMediaAttachments []*MediaAttachment `pg:"-"` |
||||
// Status being replied to
|
||||
GTSReplyToStatus *Status `pg:"-"` |
||||
// Account being replied to
|
||||
GTSReplyToAccount *Account `pg:"-"` |
||||
} |
||||
|
||||
// Visibility represents the visibility granularity of a status.
|
||||
type Visibility string |
||||
|
||||
const ( |
||||
// This status will be visible to everyone on all timelines.
|
||||
VisibilityPublic Visibility = "public" |
||||
// This status will be visible to everyone, but will only show on home timeline to followers, and in lists.
|
||||
VisibilityUnlocked Visibility = "unlocked" |
||||
// This status is viewable to followers only.
|
||||
VisibilityFollowersOnly Visibility = "followers_only" |
||||
// This status is visible to mutual followers only.
|
||||
VisibilityMutualsOnly Visibility = "mutuals_only" |
||||
// This status is visible only to mentioned recipients
|
||||
VisibilityDirect Visibility = "direct" |
||||
// Default visibility to use when no other setting can be found
|
||||
VisibilityDefault Visibility = "public" |
||||
) |
||||
|
||||
// VisibilityAdvanced denotes a set of flags that can be set on a status for fine-tuning visibility and interactivity of the status.
|
||||
type VisibilityAdvanced struct { |
||||
/* |
||||
ADVANCED SETTINGS -- These should all default to TRUE. |
||||
|
||||
If PUBLIC is selected, they will all be overwritten to TRUE regardless of what is selected. |
||||
If UNLOCKED is selected, any of them can be turned on or off in any combination. |
||||
If FOLLOWERS-ONLY or MUTUALS-ONLY are selected, boostable will always be FALSE. The others can be turned on or off as desired. |
||||
If DIRECT is selected, boostable will be FALSE, and all other flags will be TRUE. |
||||
*/ |
||||
// This status will be federated beyond the local timeline(s)
|
||||
Federated bool `pg:"default:true"` |
||||
// This status can be boosted/reblogged
|
||||
Boostable bool `pg:"default:true"` |
||||
// This status can be replied to
|
||||
Replyable bool `pg:"default:true"` |
||||
// This status can be liked/faved
|
||||
Likeable bool `pg:"default:true"` |
||||
} |
||||
|
||||
// RelevantAccounts denotes accounts that are replied to, boosted by, or mentioned in a status.
|
||||
type RelevantAccounts struct { |
||||
ReplyToAccount *Account |
||||
BoostedAccount *Account |
||||
BoostedReplyToAccount *Account |
||||
MentionedAccounts []*Account |
||||
} |
||||
@ -0,0 +1,35 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
// StatusBookmark refers to one account having a 'bookmark' of the status of another account
|
||||
type StatusBookmark struct { |
||||
// id of this bookmark in the database
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` |
||||
// when was this bookmark created
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// id of the account that created ('did') the bookmarking
|
||||
AccountID string `pg:",notnull"` |
||||
// id the account owning the bookmarked status
|
||||
TargetAccountID string `pg:",notnull"` |
||||
// database id of the status that has been bookmarked
|
||||
StatusID string `pg:",notnull"` |
||||
} |
||||
@ -0,0 +1,38 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
// StatusFave refers to a 'fave' or 'like' in the database, from one account, targeting the status of another account
|
||||
type StatusFave struct { |
||||
// id of this fave in the database
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` |
||||
// when was this fave created
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// id of the account that created ('did') the fave
|
||||
AccountID string `pg:",notnull"` |
||||
// id the account owning the faved status
|
||||
TargetAccountID string `pg:",notnull"` |
||||
// database id of the status that has been 'faved'
|
||||
StatusID string `pg:",notnull"` |
||||
|
||||
// FavedStatus is the status being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around.
|
||||
FavedStatus *Status `pg:"-"` |
||||
} |
||||
@ -0,0 +1,35 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
// StatusMute refers to one account having muted the status of another account or its own
|
||||
type StatusMute struct { |
||||
// id of this mute in the database
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` |
||||
// when was this mute created
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// id of the account that created ('did') the mute
|
||||
AccountID string `pg:",notnull"` |
||||
// id the account owning the muted status (can be the same as accountID)
|
||||
TargetAccountID string `pg:",notnull"` |
||||
// database id of the status that has been muted
|
||||
StatusID string `pg:",notnull"` |
||||
} |
||||
@ -0,0 +1,33 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
// StatusPin refers to a status 'pinned' to the top of an account
|
||||
type StatusPin struct { |
||||
// id of this pin in the database
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` |
||||
// when was this pin created
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// id of the account that created ('did') the pinning (this should always be the same as the author of the status)
|
||||
AccountID string `pg:",notnull"` |
||||
// database id of the status that has been pinned
|
||||
StatusID string `pg:",notnull"` |
||||
} |
||||
@ -0,0 +1,41 @@
|
||||
/* |
||||
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 gtsmodel |
||||
|
||||
import "time" |
||||
|
||||
// Tag represents a hashtag for gathering public statuses together
|
||||
type Tag struct { |
||||
// id of this tag in the database
|
||||
ID string `pg:",unique,type:uuid,default:gen_random_uuid(),pk,notnull"` |
||||
// name of this tag -- the tag without the hash part
|
||||
Name string `pg:",unique,pk,notnull"` |
||||
// Which account ID is the first one we saw using this tag?
|
||||
FirstSeenFromAccountID string |
||||
// when was this tag created
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// when was this tag last updated
|
||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// can our instance users use this tag?
|
||||
Useable bool `pg:",notnull,default:true"` |
||||
// can our instance users look up this tag?
|
||||
Listable bool `pg:",notnull,default:true"` |
||||
// when was this tag last used?
|
||||
LastStatusAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
} |
||||
@ -1,63 +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 model |
||||
|
||||
import "time" |
||||
|
||||
// Status represents a user-created 'post' or 'status' in the database, either remote or local
|
||||
type Status struct { |
||||
// id of the status in the database
|
||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` |
||||
// uri at which this status is reachable
|
||||
URI string `pg:",unique"` |
||||
// web url for viewing this status
|
||||
URL string `pg:",unique"` |
||||
// the html-formatted content of this status
|
||||
Content string |
||||
// when was this status created?
|
||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// when was this status updated?
|
||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` |
||||
// is this status from a local account?
|
||||
Local bool |
||||
// which account posted this status?
|
||||
AccountID string |
||||
// id of the status this status is a reply to
|
||||
InReplyToID string |
||||
// id of the status this status is a boost of
|
||||
BoostOfID string |
||||
// cw string for this status
|
||||
ContentWarning string |
||||
// visibility entry for this status
|
||||
Visibility *Visibility |
||||
} |
||||
|
||||
// Visibility represents the visibility granularity of a status. It is a combination of flags.
|
||||
type Visibility struct { |
||||
// Is this status viewable as a direct message?
|
||||
Direct bool |
||||
// Is this status viewable to followers?
|
||||
Followers bool |
||||
// Is this status viewable on the local timeline?
|
||||
Local bool |
||||
// Is this status boostable but not shown on public timelines?
|
||||
Unlisted bool |
||||
// Is this status shown on public and federated timelines?
|
||||
Public bool |
||||
} |
||||
@ -0,0 +1,544 @@
|
||||
/* |
||||
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 mastotypes |
||||
|
||||
import ( |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" |
||||
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util" |
||||
) |
||||
|
||||
// Converter is an interface for the common action of converting between mastotypes (frontend, serializable) models and internal gts models used in the database.
|
||||
// It requires access to the database because many of the conversions require pulling out database entries and counting them etc.
|
||||
type Converter interface { |
||||
// AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error
|
||||
// if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields,
|
||||
// so serve it only to an authorized user who should have permission to see it.
|
||||
AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) |
||||
|
||||
// AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error
|
||||
// if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields.
|
||||
// In other words, this is the public record that the server has of an account.
|
||||
AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) |
||||
|
||||
// AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error
|
||||
// if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields
|
||||
// (such as client id and client secret), so serve it only to an authorized user who should have permission to see it.
|
||||
AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) |
||||
|
||||
// AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error
|
||||
// if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive
|
||||
// fields sanitized so that it can be served to non-authorized accounts without revealing any private information.
|
||||
AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) |
||||
|
||||
// AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API.
|
||||
AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) |
||||
|
||||
// MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API.
|
||||
MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) |
||||
|
||||
// EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API.
|
||||
EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) |
||||
|
||||
// TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API.
|
||||
TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) |
||||
|
||||
// StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API.
|
||||
StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) |
||||
} |
||||
|
||||
type converter struct { |
||||
config *config.Config |
||||
db db.DB |
||||
} |
||||
|
||||
// New returns a new Converter
|
||||
func New(config *config.Config, db db.DB) Converter { |
||||
return &converter{ |
||||
config: config, |
||||
db: db, |
||||
} |
||||
} |
||||
|
||||
func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Account, error) { |
||||
// we can build this sensitive account easily by first getting the public account....
|
||||
mastoAccount, err := c.AccountToMastoPublic(a) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// then adding the Source object to it...
|
||||
|
||||
// check pending follow requests aimed at this account
|
||||
fr := []gtsmodel.FollowRequest{} |
||||
if err := c.db.GetFollowRequestsForAccountID(a.ID, &fr); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, fmt.Errorf("error getting follow requests: %s", err) |
||||
} |
||||
} |
||||
var frc int |
||||
if fr != nil { |
||||
frc = len(fr) |
||||
} |
||||
|
||||
mastoAccount.Source = &mastotypes.Source{ |
||||
Privacy: util.ParseMastoVisFromGTSVis(a.Privacy), |
||||
Sensitive: a.Sensitive, |
||||
Language: a.Language, |
||||
Note: a.Note, |
||||
Fields: mastoAccount.Fields, |
||||
FollowRequestsCount: frc, |
||||
} |
||||
|
||||
return mastoAccount, nil |
||||
} |
||||
|
||||
func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) { |
||||
// count followers
|
||||
followers := []gtsmodel.Follow{} |
||||
if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, fmt.Errorf("error getting followers: %s", err) |
||||
} |
||||
} |
||||
var followersCount int |
||||
if followers != nil { |
||||
followersCount = len(followers) |
||||
} |
||||
|
||||
// count following
|
||||
following := []gtsmodel.Follow{} |
||||
if err := c.db.GetFollowingByAccountID(a.ID, &following); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, fmt.Errorf("error getting following: %s", err) |
||||
} |
||||
} |
||||
var followingCount int |
||||
if following != nil { |
||||
followingCount = len(following) |
||||
} |
||||
|
||||
// count statuses
|
||||
statuses := []gtsmodel.Status{} |
||||
if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, fmt.Errorf("error getting last statuses: %s", err) |
||||
} |
||||
} |
||||
var statusesCount int |
||||
if statuses != nil { |
||||
statusesCount = len(statuses) |
||||
} |
||||
|
||||
// check when the last status was
|
||||
lastStatus := >smodel.Status{} |
||||
if err := c.db.GetLastStatusForAccountID(a.ID, lastStatus); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, fmt.Errorf("error getting last status: %s", err) |
||||
} |
||||
} |
||||
var lastStatusAt string |
||||
if lastStatus != nil { |
||||
lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339) |
||||
} |
||||
|
||||
// build the avatar and header URLs
|
||||
avi := >smodel.MediaAttachment{} |
||||
if err := c.db.GetAvatarForAccountID(avi, a.ID); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, fmt.Errorf("error getting avatar: %s", err) |
||||
} |
||||
} |
||||
aviURL := avi.URL |
||||
aviURLStatic := avi.Thumbnail.URL |
||||
|
||||
header := >smodel.MediaAttachment{} |
||||
if err := c.db.GetHeaderForAccountID(avi, a.ID); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, fmt.Errorf("error getting header: %s", err) |
||||
} |
||||
} |
||||
headerURL := header.URL |
||||
headerURLStatic := header.Thumbnail.URL |
||||
|
||||
// get the fields set on this account
|
||||
fields := []mastotypes.Field{} |
||||
for _, f := range a.Fields { |
||||
mField := mastotypes.Field{ |
||||
Name: f.Name, |
||||
Value: f.Value, |
||||
} |
||||
if !f.VerifiedAt.IsZero() { |
||||
mField.VerifiedAt = f.VerifiedAt.Format(time.RFC3339) |
||||
} |
||||
fields = append(fields, mField) |
||||
} |
||||
|
||||
var acct string |
||||
if a.Domain != "" { |
||||
// this is a remote user
|
||||
acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) |
||||
} else { |
||||
// this is a local user
|
||||
acct = a.Username |
||||
} |
||||
|
||||
return &mastotypes.Account{ |
||||
ID: a.ID, |
||||
Username: a.Username, |
||||
Acct: acct, |
||||
DisplayName: a.DisplayName, |
||||
Locked: a.Locked, |
||||
Bot: a.Bot, |
||||
CreatedAt: a.CreatedAt.Format(time.RFC3339), |
||||
Note: a.Note, |
||||
URL: a.URL, |
||||
Avatar: aviURL, |
||||
AvatarStatic: aviURLStatic, |
||||
Header: headerURL, |
||||
HeaderStatic: headerURLStatic, |
||||
FollowersCount: followersCount, |
||||
FollowingCount: followingCount, |
||||
StatusesCount: statusesCount, |
||||
LastStatusAt: lastStatusAt, |
||||
Emojis: nil, // TODO: implement this
|
||||
Fields: fields, |
||||
}, nil |
||||
} |
||||
|
||||
func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Application, error) { |
||||
return &mastotypes.Application{ |
||||
ID: a.ID, |
||||
Name: a.Name, |
||||
Website: a.Website, |
||||
RedirectURI: a.RedirectURI, |
||||
ClientID: a.ClientID, |
||||
ClientSecret: a.ClientSecret, |
||||
VapidKey: a.VapidKey, |
||||
}, nil |
||||
} |
||||
|
||||
func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*mastotypes.Application, error) { |
||||
return &mastotypes.Application{ |
||||
Name: a.Name, |
||||
Website: a.Website, |
||||
}, nil |
||||
} |
||||
|
||||
func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { |
||||
return mastotypes.Attachment{ |
||||
ID: a.ID, |
||||
Type: string(a.Type), |
||||
URL: a.URL, |
||||
PreviewURL: a.Thumbnail.URL, |
||||
RemoteURL: a.RemoteURL, |
||||
PreviewRemoteURL: a.Thumbnail.RemoteURL, |
||||
Meta: mastotypes.MediaMeta{ |
||||
Original: mastotypes.MediaDimensions{ |
||||
Width: a.FileMeta.Original.Width, |
||||
Height: a.FileMeta.Original.Height, |
||||
Size: fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height), |
||||
Aspect: float32(a.FileMeta.Original.Aspect), |
||||
}, |
||||
Small: mastotypes.MediaDimensions{ |
||||
Width: a.FileMeta.Small.Width, |
||||
Height: a.FileMeta.Small.Height, |
||||
Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height), |
||||
Aspect: float32(a.FileMeta.Small.Aspect), |
||||
}, |
||||
Focus: mastotypes.MediaFocus{ |
||||
X: a.FileMeta.Focus.X, |
||||
Y: a.FileMeta.Focus.Y, |
||||
}, |
||||
}, |
||||
Description: a.Description, |
||||
Blurhash: a.Blurhash, |
||||
}, nil |
||||
} |
||||
|
||||
func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) { |
||||
target := >smodel.Account{} |
||||
if err := c.db.GetByID(m.TargetAccountID, target); err != nil { |
||||
return mastotypes.Mention{}, err |
||||
} |
||||
|
||||
var local bool |
||||
if target.Domain == "" { |
||||
local = true |
||||
} |
||||
|
||||
var acct string |
||||
if local { |
||||
acct = fmt.Sprintf("@%s", target.Username) |
||||
} else { |
||||
acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain) |
||||
} |
||||
|
||||
return mastotypes.Mention{ |
||||
ID: target.ID, |
||||
Username: target.Username, |
||||
URL: target.URL, |
||||
Acct: acct, |
||||
}, nil |
||||
} |
||||
|
||||
func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) { |
||||
return mastotypes.Emoji{ |
||||
Shortcode: e.Shortcode, |
||||
URL: e.ImageURL, |
||||
StaticURL: e.ImageStaticURL, |
||||
VisibleInPicker: e.VisibleInPicker, |
||||
Category: e.CategoryID, |
||||
}, nil |
||||
} |
||||
|
||||
func (c *converter) TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) { |
||||
tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name) |
||||
|
||||
return mastotypes.Tag{ |
||||
Name: t.Name, |
||||
URL: tagURL, // we don't serve URLs with collections of tagged statuses (FOR NOW) so this is purely for mastodon compatibility ¯\_(ツ)_/¯
|
||||
}, nil |
||||
} |
||||
|
||||
func (c *converter) StatusToMasto( |
||||
s *gtsmodel.Status, |
||||
targetAccount *gtsmodel.Account, |
||||
requestingAccount *gtsmodel.Account, |
||||
boostOfAccount *gtsmodel.Account, |
||||
replyToAccount *gtsmodel.Account, |
||||
reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) { |
||||
|
||||
repliesCount, err := c.db.GetReplyCountForStatus(s) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error counting replies: %s", err) |
||||
} |
||||
|
||||
reblogsCount, err := c.db.GetReblogCountForStatus(s) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error counting reblogs: %s", err) |
||||
} |
||||
|
||||
favesCount, err := c.db.GetFaveCountForStatus(s) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error counting faves: %s", err) |
||||
} |
||||
|
||||
var faved bool |
||||
var reblogged bool |
||||
var bookmarked bool |
||||
var pinned bool |
||||
var muted bool |
||||
|
||||
// requestingAccount will be nil for public requests without auth
|
||||
// But if it's not nil, we can also get information about the requestingAccount's interaction with this status
|
||||
if requestingAccount != nil { |
||||
faved, err = c.db.StatusFavedBy(s, requestingAccount.ID) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error checking if requesting account has faved status: %s", err) |
||||
} |
||||
|
||||
reblogged, err = c.db.StatusRebloggedBy(s, requestingAccount.ID) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error checking if requesting account has reblogged status: %s", err) |
||||
} |
||||
|
||||
muted, err = c.db.StatusMutedBy(s, requestingAccount.ID) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err) |
||||
} |
||||
|
||||
bookmarked, err = c.db.StatusBookmarkedBy(s, requestingAccount.ID) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err) |
||||
} |
||||
|
||||
pinned, err = c.db.StatusPinnedBy(s, requestingAccount.ID) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error checking if requesting account has pinned status: %s", err) |
||||
} |
||||
} |
||||
|
||||
var mastoRebloggedStatus *mastotypes.Status // TODO
|
||||
|
||||
var mastoApplication *mastotypes.Application |
||||
if s.CreatedWithApplicationID != "" { |
||||
gtsApplication := >smodel.Application{} |
||||
if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil { |
||||
return nil, fmt.Errorf("error fetching application used to create status: %s", err) |
||||
} |
||||
mastoApplication, err = c.AppToMastoPublic(gtsApplication) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error parsing application used to create status: %s", err) |
||||
} |
||||
} |
||||
|
||||
mastoTargetAccount, err := c.AccountToMastoPublic(targetAccount) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error parsing account of status author: %s", err) |
||||
} |
||||
|
||||
mastoAttachments := []mastotypes.Attachment{} |
||||
// the status might already have some gts attachments on it if it's not been pulled directly from the database
|
||||
// if so, we can directly convert the gts attachments into masto ones
|
||||
if s.GTSMediaAttachments != nil { |
||||
for _, gtsAttachment := range s.GTSMediaAttachments { |
||||
mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error converting attachment with id %s: %s", gtsAttachment.ID, err) |
||||
} |
||||
mastoAttachments = append(mastoAttachments, mastoAttachment) |
||||
} |
||||
// the status doesn't have gts attachments on it, but it does have attachment IDs
|
||||
// in this case, we need to pull the gts attachments from the db to convert them into masto ones
|
||||
} else { |
||||
for _, a := range s.Attachments { |
||||
gtsAttachment := >smodel.MediaAttachment{} |
||||
if err := c.db.GetByID(a, gtsAttachment); err != nil { |
||||
return nil, fmt.Errorf("error getting attachment with id %s: %s", a, err) |
||||
} |
||||
mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error converting attachment with id %s: %s", a, err) |
||||
} |
||||
mastoAttachments = append(mastoAttachments, mastoAttachment) |
||||
} |
||||
} |
||||
|
||||
mastoMentions := []mastotypes.Mention{} |
||||
// the status might already have some gts mentions on it if it's not been pulled directly from the database
|
||||
// if so, we can directly convert the gts mentions into masto ones
|
||||
if s.GTSMentions != nil { |
||||
for _, gtsMention := range s.GTSMentions { |
||||
mastoMention, err := c.MentionToMasto(gtsMention) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) |
||||
} |
||||
mastoMentions = append(mastoMentions, mastoMention) |
||||
} |
||||
// the status doesn't have gts mentions on it, but it does have mention IDs
|
||||
// in this case, we need to pull the gts mentions from the db to convert them into masto ones
|
||||
} else { |
||||
for _, m := range s.Mentions { |
||||
gtsMention := >smodel.Mention{} |
||||
if err := c.db.GetByID(m, gtsMention); err != nil { |
||||
return nil, fmt.Errorf("error getting mention with id %s: %s", m, err) |
||||
} |
||||
mastoMention, err := c.MentionToMasto(gtsMention) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) |
||||
} |
||||
mastoMentions = append(mastoMentions, mastoMention) |
||||
} |
||||
} |
||||
|
||||
mastoTags := []mastotypes.Tag{} |
||||
// the status might already have some gts tags on it if it's not been pulled directly from the database
|
||||
// if so, we can directly convert the gts tags into masto ones
|
||||
if s.GTSTags != nil { |
||||
for _, gtsTag := range s.GTSTags { |
||||
mastoTag, err := c.TagToMasto(gtsTag) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) |
||||
} |
||||
mastoTags = append(mastoTags, mastoTag) |
||||
} |
||||
// the status doesn't have gts tags on it, but it does have tag IDs
|
||||
// in this case, we need to pull the gts tags from the db to convert them into masto ones
|
||||
} else { |
||||
for _, t := range s.Tags { |
||||
gtsTag := >smodel.Tag{} |
||||
if err := c.db.GetByID(t, gtsTag); err != nil { |
||||
return nil, fmt.Errorf("error getting tag with id %s: %s", t, err) |
||||
} |
||||
mastoTag, err := c.TagToMasto(gtsTag) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) |
||||
} |
||||
mastoTags = append(mastoTags, mastoTag) |
||||
} |
||||
} |
||||
|
||||
mastoEmojis := []mastotypes.Emoji{} |
||||
// the status might already have some gts emojis on it if it's not been pulled directly from the database
|
||||
// if so, we can directly convert the gts emojis into masto ones
|
||||
if s.GTSEmojis != nil { |
||||
for _, gtsEmoji := range s.GTSEmojis { |
||||
mastoEmoji, err := c.EmojiToMasto(gtsEmoji) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) |
||||
} |
||||
mastoEmojis = append(mastoEmojis, mastoEmoji) |
||||
} |
||||
// the status doesn't have gts emojis on it, but it does have emoji IDs
|
||||
// in this case, we need to pull the gts emojis from the db to convert them into masto ones
|
||||
} else { |
||||
for _, e := range s.Emojis { |
||||
gtsEmoji := >smodel.Emoji{} |
||||
if err := c.db.GetByID(e, gtsEmoji); err != nil { |
||||
return nil, fmt.Errorf("error getting emoji with id %s: %s", e, err) |
||||
} |
||||
mastoEmoji, err := c.EmojiToMasto(gtsEmoji) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) |
||||
} |
||||
mastoEmojis = append(mastoEmojis, mastoEmoji) |
||||
} |
||||
} |
||||
|
||||
var mastoCard *mastotypes.Card |
||||
var mastoPoll *mastotypes.Poll |
||||
|
||||
return &mastotypes.Status{ |
||||
ID: s.ID, |
||||
CreatedAt: s.CreatedAt.Format(time.RFC3339), |
||||
InReplyToID: s.InReplyToID, |
||||
InReplyToAccountID: s.InReplyToAccountID, |
||||
Sensitive: s.Sensitive, |
||||
SpoilerText: s.ContentWarning, |
||||
Visibility: util.ParseMastoVisFromGTSVis(s.Visibility), |
||||
Language: s.Language, |
||||
URI: s.URI, |
||||
URL: s.URL, |
||||
RepliesCount: repliesCount, |
||||
ReblogsCount: reblogsCount, |
||||
FavouritesCount: favesCount, |
||||
Favourited: faved, |
||||
Reblogged: reblogged, |
||||
Muted: muted, |
||||
Bookmarked: bookmarked, |
||||
Pinned: pinned, |
||||
Content: s.Content, |
||||
Reblog: mastoRebloggedStatus, |
||||
Application: mastoApplication, |
||||
Account: mastoTargetAccount, |
||||
MediaAttachments: mastoAttachments, |
||||
Mentions: mastoMentions, |
||||
Tags: mastoTags, |
||||
Emojis: mastoEmojis, |
||||
Card: mastoCard, // TODO: implement cards
|
||||
Poll: mastoPoll, // TODO: implement polls
|
||||
Text: s.Text, |
||||
}, nil |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue