diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go index e57e27627..bc595734f 100644 --- a/internal/apimodule/fileserver/fileserver.go +++ b/internal/apimodule/fileserver/fileserver.go @@ -37,7 +37,7 @@ const ( mediaSizeKey = "media_size" fileNameKey = "file_name" - filesPath = "files" + filesPath = "files" ) // fileServer implements the RESTAPIModule interface. diff --git a/internal/apimodule/security/security.go b/internal/apimodule/security/security.go index d98d9703e..cfac2ce1e 100644 --- a/internal/apimodule/security/security.go +++ b/internal/apimodule/security/security.go @@ -29,7 +29,7 @@ import ( // module implements the apiclient interface type module struct { config *config.Config - log *logrus.Logger + log *logrus.Logger } // New returns a new security module diff --git a/internal/apimodule/status/status.go b/internal/apimodule/status/status.go index 965f8cad2..cb4dab134 100644 --- a/internal/apimodule/status/status.go +++ b/internal/apimodule/status/status.go @@ -21,7 +21,9 @@ 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" @@ -77,7 +79,7 @@ func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler // 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.MethodGet, basePathWithID, m.muxHandler) + r.AttachHandler(http.MethodGet, basePathWithID, m.muxHandler) return nil } @@ -89,6 +91,10 @@ func (m *statusModule) CreateTables(db db.DB) error { >smodel.Follow{}, >smodel.FollowRequest{}, >smodel.Status{}, + >smodel.StatusFave{}, + >smodel.StatusBookmark{}, + >smodel.StatusMute{}, + >smodel.StatusPin{}, >smodel.Application{}, >smodel.EmailDomainBlock{}, >smodel.MediaAttachment{}, @@ -105,13 +111,14 @@ func (m *statusModule) CreateTables(db db.DB) error { return nil } -// func (m *statusModule) muxHandler(c *gin.Context) { -// ru := c.Request.RequestURI -// if strings.HasPrefix(ru, verifyPath) { -// m.accountVerifyGETHandler(c) -// } else if strings.HasPrefix(ru, updateCredentialsPath) { -// m.accountUpdateCredentialsPATCHHandler(c) -// } else { -// m.accountGETHandler(c) -// } -// } +func (m *statusModule) muxHandler(c *gin.Context) { + m.log.Debug("entering mux handler") + ru := c.Request.RequestURI + if strings.HasPrefix(ru, contextPath) { + // TODO + } else if strings.HasPrefix(ru, rebloggedPath) { + // TODO + } else { + m.statusGETHandler(c) + } +} diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go index 6a0f7321c..0f5b00200 100644 --- a/internal/apimodule/status/statuscreate.go +++ b/internal/apimodule/status/statuscreate.go @@ -97,18 +97,20 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) { 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, + 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 @@ -181,80 +183,11 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) { /* FROM THIS POINT ONWARDS WE ARE JUST CREATING THE FRONTEND REPRESENTATION OF THE STATUS TO RETURN TO THE SUBMITTER */ - mastoVis := util.ParseMastoVisFromGTSVis(newStatus.Visibility) - - mastoAccount, err := m.mastoConverter.AccountToMastoPublic(authed.Account) + mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, newStatus.GTSReplyToStatus) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - - mastoAttachments := []mastotypes.Attachment{} - for _, a := range newStatus.GTSMediaAttachments { - ma, err := m.mastoConverter.AttachmentToMasto(a) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - mastoAttachments = append(mastoAttachments, ma) - } - - mastoMentions := []mastotypes.Mention{} - for _, gtsm := range newStatus.GTSMentions { - mm, err := m.mastoConverter.MentionToMasto(gtsm) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - mastoMentions = append(mastoMentions, mm) - } - - mastoApplication, err := m.mastoConverter.AppToMastoPublic(authed.Application) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - mastoTags := []mastotypes.Tag{} - for _, gtst := range newStatus.GTSTags { - mt, err := m.mastoConverter.TagToMasto(gtst) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - mastoTags = append(mastoTags, mt) - } - - mastoEmojis := []mastotypes.Emoji{} - for _, gtse := range newStatus.GTSEmojis { - me, err := m.mastoConverter.EmojiToMasto(gtse) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - mastoEmojis = append(mastoEmojis, me) - } - - mastoStatus := &mastotypes.Status{ - ID: newStatus.ID, - CreatedAt: newStatus.CreatedAt.Format(time.RFC3339), - InReplyToID: newStatus.InReplyToID, - InReplyToAccountID: newStatus.InReplyToAccountID, - Sensitive: newStatus.Sensitive, - SpoilerText: newStatus.ContentWarning, - Visibility: mastoVis, - Language: newStatus.Language, - URI: newStatus.URI, - URL: newStatus.URL, - Content: newStatus.Content, - Application: mastoApplication, - Account: mastoAccount, - MediaAttachments: mastoAttachments, - Mentions: mastoMentions, - Tags: mastoTags, - Emojis: mastoEmojis, - Text: form.Status, - } c.JSON(http.StatusOK, mastoStatus) } @@ -487,7 +420,7 @@ func (m *statusModule) parseMentions(form *advancedStatusCreateForm, accountID s if err := m.db.Put(menchie); err != nil { return fmt.Errorf("error putting mentions in db: %s", err) } - menchies = append(menchies, menchie.ID) + menchies = append(menchies, menchie.TargetAccountID) } // add full populated gts menchies to the status for passing them around conveniently status.GTSMentions = gtsMenchies diff --git a/internal/apimodule/status/statuscreate_test.go b/internal/apimodule/status/statuscreate_test.go index ccd58bc14..8439b694a 100644 --- a/internal/apimodule/status/statuscreate_test.go +++ b/internal/apimodule/status/statuscreate_test.go @@ -162,7 +162,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() { assert.Len(suite.T(), statusReply.Tags, 1) assert.Equal(suite.T(), mastomodel.Tag{ Name: "helloworld", - URL: "http://localhost:8080/tags/helloworld", + URL: "http://localhost:8080/tags/helloworld", }, statusReply.Tags[0]) gtsTag := >smodel.Tag{} @@ -185,7 +185,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { 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": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "}, + "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) diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go new file mode 100644 index 000000000..4d1e3abbb --- /dev/null +++ b/internal/apimodule/status/statusget.go @@ -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 . +*/ + +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) +} diff --git a/internal/apimodule/status/statusget_test.go b/internal/apimodule/status/statusget_test.go new file mode 100644 index 000000000..4e25dfbcc --- /dev/null +++ b/internal/apimodule/status/statusget_test.go @@ -0,0 +1,167 @@ +/* + 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 . +*/ + +package status + +import ( + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "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 *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 = New(suite.config, suite.db, suite.oauthServer, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*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)) +} diff --git a/internal/db/db.go b/internal/db/db.go index 074d7926e..106780f5d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -187,6 +187,54 @@ type DB interface { // That is, it returns true if account1 blocks account2, OR if account2 blocks account1. Blocked(account1 string, account2 string) (bool, error) + // StatusVisible returns true if targetStatus is visible to requestingAccount, based on the + // privacy settings of the status, and any blocks/mutes that might exist between the two accounts + // or account domains. + // + // StatusVisible will also check through the given slice of 'otherRelevantAccounts', which should include: + // + // 1. Accounts mentioned in the targetStatus + // + // 2. Accounts replied to by the target status + // + // 3. Accounts boosted by the target status + // + // Will return an error if something goes wrong while pulling stuff out of the database. + StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) + + // Follows returns true if sourceAccount follows target account, or an error if something goes wrong while finding out. + Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) + + // Mutuals returns true if account1 and account2 both follow each other, or an error if something goes wrong while finding out. + Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) + + // PullRelevantAccountsFromStatus returns all accounts mentioned in a status, replied to by a status, or boosted by a status + PullRelevantAccountsFromStatus(status *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) + + // GetReplyCountForStatus returns the amount of replies recorded for a status, or an error if something goes wrong + GetReplyCountForStatus(status *gtsmodel.Status) (int, error) + + // GetReblogCountForStatus returns the amount of reblogs/boosts recorded for a status, or an error if something goes wrong + GetReblogCountForStatus(status *gtsmodel.Status) (int, error) + + // GetFaveCountForStatus returns the amount of faves/likes recorded for a status, or an error if something goes wrong + GetFaveCountForStatus(status *gtsmodel.Status) (int, error) + + // StatusFavedBy checks if a given status has been faved by a given account ID + StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // StatusRebloggedBy checks if a given status has been reblogged/boosted by a given account ID + StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // StatusMutedBy checks if a given status has been muted by a given account ID + StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // StatusBookmarkedBy checks if a given status has been bookmarked by a given account ID + StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // StatusPinnedBy checks if a given status has been pinned by a given account ID + StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) + /* USEFUL CONVERSION FUNCTIONS */ diff --git a/internal/db/gtsmodel/status.go b/internal/db/gtsmodel/status.go index 1e5c1d95b..3b4b84405 100644 --- a/internal/db/gtsmodel/status.go +++ b/internal/db/gtsmodel/status.go @@ -34,7 +34,7 @@ type Status struct { Attachments []string `pg:",array"` // Database IDs of any tags used in this status Tags []string `pg:",array"` - // Database IDs of any mentions in this status + // 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"` @@ -60,11 +60,15 @@ type Status struct { 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 @@ -105,6 +109,7 @@ const ( 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. @@ -123,3 +128,11 @@ type VisibilityAdvanced struct { // 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 +} diff --git a/internal/db/gtsmodel/statusbookmark.go b/internal/db/gtsmodel/statusbookmark.go new file mode 100644 index 000000000..6246334e3 --- /dev/null +++ b/internal/db/gtsmodel/statusbookmark.go @@ -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 . +*/ + +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"` +} diff --git a/internal/db/gtsmodel/statusfave.go b/internal/db/gtsmodel/statusfave.go new file mode 100644 index 000000000..852998387 --- /dev/null +++ b/internal/db/gtsmodel/statusfave.go @@ -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 . +*/ + +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"` +} diff --git a/internal/db/gtsmodel/statusmute.go b/internal/db/gtsmodel/statusmute.go new file mode 100644 index 000000000..53c15e5b5 --- /dev/null +++ b/internal/db/gtsmodel/statusmute.go @@ -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 . +*/ + +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"` +} diff --git a/internal/db/gtsmodel/statuspin.go b/internal/db/gtsmodel/statuspin.go new file mode 100644 index 000000000..1df333387 --- /dev/null +++ b/internal/db/gtsmodel/statuspin.go @@ -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 . +*/ + +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"` +} diff --git a/internal/db/pg.go b/internal/db/pg.go index 291252875..4098a640b 100644 --- a/internal/db/pg.go +++ b/internal/db/pg.go @@ -38,7 +38,6 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" "github.com/superseriousbusiness/gotosocial/internal/util" "golang.org/x/crypto/bcrypt" ) @@ -556,160 +555,266 @@ func (ps *postgresService) Blocked(account1 string, account2 string) (bool, erro return blocked, nil } +func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) { + l := ps.log.WithField("func", "StatusVisible") + + // if target account is suspended then don't show the status + if !targetAccount.SuspendedAt.IsZero() { + l.Debug("target account suspended at is not zero") + return false, nil + } + + // if the target user doesn't exist (anymore) then the status also shouldn't be visible + targetUser := >smodel.User{} + if err := ps.conn.Model(targetUser).Where("account_id = ?", targetAccount.ID).Select(); err != nil { + l.Debug("target user could not be selected") + if err == pg.ErrNoRows { + return false, ErrNoEntries{} + } else { + return false, err + } + } + + // if target user is disabled, not yet approved, or not confirmed then don't show the status + // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!) + if targetUser.Disabled || !targetUser.Approved || targetUser.ConfirmedAt.IsZero() { + l.Debug("target user is disabled, not approved, or not confirmed") + return false, nil + } + + // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed. + // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't. + if requestingAccount == nil { + if targetStatus.Visibility == gtsmodel.VisibilityPublic { + return true, nil + } + return false, nil + } + + // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten + // this far (ie., been authed) in the first place: this is just for safety. + if !requestingAccount.SuspendedAt.IsZero() { + return false, nil + } + + // check if we have a local account -- if so we can check the user for that account in the DB + if requestingAccount.Domain == "" { + requestingUser := >smodel.User{} + if err := ps.conn.Model(requestingUser).Where("account_id = ?", requestingAccount.ID).Select(); err != nil { + // if the requesting account is local but doesn't have a corresponding user in the db this is a problem + if err == pg.ErrNoRows { + return false, nil + } else { + return false, err + } + } + // okay, user exists, so make sure it has full privileges/is confirmed/approved + if requestingUser.Disabled || !requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() { + return false, nil + } + } + + // At this point we have a populated targetAccount, targetStatus, and requestingAccount, so we can check for blocks and whathaveyou + // First check if a block exists directly between the target account (which authored the status) and the requesting account. + if blocked, err := ps.Blocked(targetAccount.ID, requestingAccount.ID); err != nil { + // something went wrong figuring out if the accounts have a block + return false, err + } else if blocked { + // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please + return false, nil + } + + // check other accounts mentioned/boosted by/replied to by the status, if they exist + if relevantAccounts != nil { + // status replies to account id + if relevantAccounts.ReplyToAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.ReplyToAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status boosts accounts id + if relevantAccounts.BoostedAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status boosts a reply to account id + if relevantAccounts.BoostedReplyToAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.BoostedReplyToAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status mentions accounts + for _, a := range relevantAccounts.MentionedAccounts { + if blocked, err := ps.Blocked(a.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + } + + // at this point we know neither account blocks the other, or another account mentioned or otherwise referred to in the status + // that means it's now just a matter of checking the visibility settings of the status itself + switch targetStatus.Visibility { + case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked: + // no problem here, just return OK + return true, nil + case gtsmodel.VisibilityFollowersOnly: + // check one-way follow + follows, err := ps.Follows(requestingAccount, targetAccount) + if err != nil { + return false, err + } + if !follows { + return false, nil + } + return true, nil + case gtsmodel.VisibilityMutualsOnly: + // check mutual follow + mutuals, err := ps.Mutuals(requestingAccount, targetAccount) + if err != nil { + return false, err + } + if !mutuals { + return false, nil + } + return true, nil + case gtsmodel.VisibilityDirect: + // make sure the requesting account is mentioned in the status + for _, menchie := range targetStatus.Mentions { + if menchie == requestingAccount.ID { + return true, nil // yep it's mentioned! + } + } + return false, nil // it's not mentioned -_- + } + + return false, errors.New("reached the end of StatusVisible with no result") +} + +func (ps *postgresService) Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) { + return ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", sourceAccount.ID).Where("target_account_id = ?", targetAccount.ID).Exists() +} + +func (ps *postgresService) Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) { + // make sure account 1 follows account 2 + f1, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account1.ID).Where("target_account_id = ?", account2.ID).Exists() + if err != nil { + if err == pg.ErrNoRows { + return false, nil + } else { + return false, err + } + } + + // make sure account 2 follows account 1 + f2, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account2.ID).Where("target_account_id = ?", account1.ID).Exists() + if err != nil { + if err == pg.ErrNoRows { + return false, nil + } else { + return false, err + } + } + + return f1 && f2, nil +} + +func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) { + accounts := >smodel.RelevantAccounts{ + MentionedAccounts: []*gtsmodel.Account{}, + } + + // get the replied to account from the status and add it to the pile + if targetStatus.InReplyToAccountID != "" { + repliedToAccount := >smodel.Account{} + if err := ps.conn.Model(repliedToAccount).Where("id = ?", targetStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.ReplyToAccount = repliedToAccount + } + + // get the boosted account from the status and add it to the pile + if targetStatus.BoostOfID != "" { + // retrieve the boosted status first + boostedStatus := >smodel.Status{} + if err := ps.conn.Model(boostedStatus).Where("id = ?", targetStatus.BoostOfID).Select(); err != nil { + return accounts, err + } + boostedAccount := >smodel.Account{} + if err := ps.conn.Model(boostedAccount).Where("id = ?", boostedStatus.AccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedAccount = boostedAccount + + // the boosted status might be a reply to another account so we should get that too + if boostedStatus.InReplyToAccountID != "" { + boostedStatusRepliedToAccount := >smodel.Account{} + if err := ps.conn.Model(boostedStatusRepliedToAccount).Where("id = ?", boostedStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount + } + } + + // now get all accounts with IDs that are mentioned in the status + for _, mentionedAccountID := range targetStatus.Mentions { + mentionedAccount := >smodel.Account{} + if err := ps.conn.Model(mentionedAccount).Where("id = ?", mentionedAccountID).Select(); err != nil { + return accounts, err + } + accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount) + } + + return accounts, nil +} + +func (ps *postgresService) GetReplyCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.Status{}).Where("in_reply_to_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetReblogCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetFaveCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Count() +} + +func (ps *postgresService) StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusMute{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusPin{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + /* CONVERSION FUNCTIONS */ -// AccountToMastoSensitive takes an internal account model and transforms it into an account ready to be served through the API. -// The resulting account fits the specifications for the path /api/v1/accounts/verify_credentials, as described here: -// https://docs.joinmastodon.org/methods/accounts/. Note that it's *sensitive* because it's only meant to be exposed to the user -// that the account actually belongs to. -func (ps *postgresService) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Account, error) { - // we can build this sensitive account easily by first getting the public account.... - mastoAccount, err := ps.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 := ps.GetFollowRequestsForAccountID(a.ID, &fr); err != nil { - if _, ok := err.(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 (ps *postgresService) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) { - // count followers - followers := []gtsmodel.Follow{} - if err := ps.GetFollowersByAccountID(a.ID, &followers); err != nil { - if _, ok := err.(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 := ps.GetFollowingByAccountID(a.ID, &following); err != nil { - if _, ok := err.(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 := ps.GetStatusesByAccountID(a.ID, &statuses); err != nil { - if _, ok := err.(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 := ps.GetLastStatusForAccountID(a.ID, lastStatus); err != nil { - if _, ok := err.(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 := ps.GetAvatarForAccountID(avi, a.ID); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting avatar: %s", err) - } - } - aviURL := avi.File.Path - aviURLStatic := avi.Thumbnail.Path - - header := >smodel.MediaAttachment{} - if err := ps.GetHeaderForAccountID(avi, a.ID); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting header: %s", err) - } - } - headerURL := header.File.Path - headerURLStatic := header.Thumbnail.Path - - // 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 (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { menchies := []*gtsmodel.Mention{} for _, a := range targetAccounts { diff --git a/internal/mastotypes/converter.go b/internal/mastotypes/converter.go index 9db39150d..c6516422c 100644 --- a/internal/mastotypes/converter.go +++ b/internal/mastotypes/converter.go @@ -63,6 +63,9 @@ type Converter interface { // 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 { @@ -318,3 +321,211 @@ func (c *converter) TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) { 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) + } + + 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 + + application := >smodel.Application{} + if err := c.db.GetByID(s.CreatedWithApplicationID, application); err != nil { + return nil, fmt.Errorf("error fetching application used to create status: %s", err) + } + mastoApplication, err := c.AppToMastoPublic(application) + 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 +} diff --git a/internal/mastotypes/mastomodel/emoji.go b/internal/mastotypes/mastomodel/emoji.go index e9a0322bc..c50ca6343 100644 --- a/internal/mastotypes/mastomodel/emoji.go +++ b/internal/mastotypes/mastomodel/emoji.go @@ -42,7 +42,7 @@ type Emoji struct { // EmojiCreateRequest represents a request to create a custom emoji made through the admin API. type EmojiCreateRequest struct { // Desired shortcode for the emoji, without surrounding colons. This must be unique for the domain. - Shortcode string `form:"shortcode" validation:"required"` + Shortcode string `form:"shortcode" validation:"required"` // Image file to use for the emoji. Must be png or gif and no larger than 50kb. - Image *multipart.FileHeader `form:"image" validation:"required"` + Image *multipart.FileHeader `form:"image" validation:"required"` } diff --git a/internal/mastotypes/mastomodel/tag.go b/internal/mastotypes/mastomodel/tag.go index 6dda3fb5d..82e6e6618 100644 --- a/internal/mastotypes/mastomodel/tag.go +++ b/internal/mastotypes/mastomodel/tag.go @@ -20,8 +20,8 @@ package mastotypes // Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/ type Tag struct { - // The value of the hashtag after the # sign. - Name string `json:"name"` - // A link to the hashtag on the instance. - URL string `json:"url"` + // The value of the hashtag after the # sign. + Name string `json:"name"` + // A link to the hashtag on the instance. + URL string `json:"url"` } diff --git a/internal/media/media.go b/internal/media/media.go index 93a899e00..6546501ab 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -34,19 +34,19 @@ import ( const ( // Key for small/thumbnail versions of media - MediaSmall = "small" + MediaSmall = "small" // Key for original/fullsize versions of media and emoji - MediaOriginal = "original" + MediaOriginal = "original" // Key for static (non-animated) versions of emoji - MediaStatic = "static" + MediaStatic = "static" // Key for media attachments MediaAttachment = "attachment" // Key for profile header - MediaHeader = "header" + MediaHeader = "header" // Key for profile avatar - MediaAvatar = "avatar" + MediaAvatar = "avatar" // Key for emoji type - MediaEmoji = "emoji" + MediaEmoji = "emoji" // Maximum permitted bytes of an emoji upload (50kb) EmojiMaxBytes = 51200 diff --git a/testrig/db.go b/testrig/db.go index afec79ccd..6f61b3763 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -38,6 +38,10 @@ var testModels []interface{} = []interface{}{ >smodel.MediaAttachment{}, >smodel.Mention{}, >smodel.Status{}, + >smodel.StatusFave{}, + >smodel.StatusBookmark{}, + >smodel.StatusMute{}, + >smodel.StatusPin{}, >smodel.Tag{}, >smodel.User{}, >smodel.Emoji{},