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