commit 6f5c045284d34ba580d3007f70b97e05d6760527
parent ac9adb172b09882b12659a3e43a94d724eb65378
Author: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date: Sat, 8 May 2021 14:25:55 +0200
Ap (#14)
Big restructuring and initial work on activitypub
Diffstat:
251 files changed, 12611 insertions(+), 10634 deletions(-)
diff --git a/go.mod b/go.mod
@@ -8,6 +8,7 @@ require (
github.com/gin-contrib/sessions v0.0.3
github.com/gin-gonic/gin v1.6.3
github.com/go-fed/activity v1.0.0
+ github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5
github.com/go-pg/pg/extra/pgdebug v0.2.0
github.com/go-pg/pg/v10 v10.8.0
github.com/golang/mock v1.4.4 // indirect
diff --git a/internal/api/apimodule.go b/internal/api/apimodule.go
@@ -0,0 +1,37 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package api
+
+import (
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+// ClientModule represents a chunk of code (usually contained in a single package) that adds a set
+// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)
+// A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/
+type ClientModule interface {
+ Route(s router.Router) error
+}
+
+// FederationModule represents a chunk of code (usually contained in a single package) that adds a set
+// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)
+// Unlike ClientAPIModule, federation API module is not intended to be interacted with by clients directly -- it is primarily a server-to-server interface.
+type FederationModule interface {
+ Route(s router.Router) error
+}
diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go
@@ -0,0 +1,85 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // IDKey is the key to use for retrieving account ID in requests
+ IDKey = "id"
+ // BasePath is the base API path for this module
+ BasePath = "/api/v1/accounts"
+ // BasePathWithID is the base path for this module with the ID key
+ BasePathWithID = BasePath + "/:" + IDKey
+ // VerifyPath is for verifying account credentials
+ VerifyPath = BasePath + "/verify_credentials"
+ // UpdateCredentialsPath is for updating account credentials
+ UpdateCredentialsPath = BasePath + "/update_credentials"
+)
+
+// Module implements the ClientAPIModule interface for account-related actions
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new account module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *Module) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler)
+ r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
+ r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler)
+ return nil
+}
+
+func (m *Module) muxHandler(c *gin.Context) {
+ ru := c.Request.RequestURI
+ switch c.Request.Method {
+ case http.MethodGet:
+ if strings.HasPrefix(ru, VerifyPath) {
+ m.AccountVerifyGETHandler(c)
+ } else {
+ m.AccountGETHandler(c)
+ }
+ case http.MethodPatch:
+ if strings.HasPrefix(ru, UpdateCredentialsPath) {
+ m.AccountUpdateCredentialsPATCHHandler(c)
+ }
+ }
+}
diff --git a/internal/api/client/account/account_test.go b/internal/api/client/account/account_test.go
@@ -0,0 +1,40 @@
+package account_test
+
+import (
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type AccountStandardTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ tc typeutils.TypeConverter
+ storage storage.Storage
+ federator federation.Federator
+ processor message.Processor
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+
+ // module being tested
+ accountModule *account.Module
+}
diff --git a/internal/api/client/account/accountcreate.go b/internal/api/client/account/accountcreate.go
@@ -0,0 +1,113 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "errors"
+ "net"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// AccountCreatePOSTHandler handles create account requests, validates them,
+// and puts them in the database if they're valid.
+// It should be served as a POST at /api/v1/accounts
+func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "accountCreatePOSTHandler")
+ authed, err := oauth.Authed(c, true, true, false, false)
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ l.Trace("parsing request form")
+ form := &model.AccountCreateRequest{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+ return
+ }
+
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateAccount(form, m.config.AccountsConfig); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ clientIP := c.ClientIP()
+ l.Tracef("attempting to parse client ip address %s", clientIP)
+ signUpIP := net.ParseIP(clientIP)
+ if signUpIP == nil {
+ l.Debugf("error validating sign up ip address %s", clientIP)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"})
+ return
+ }
+
+ form.IP = signUpIP
+
+ ti, err := m.processor.AccountCreate(authed, form)
+ if err != nil {
+ l.Errorf("internal server error while creating new account: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, ti)
+}
+
+// validateCreateAccount checks through all the necessary prerequisites for creating a new account,
+// according to the provided account create request. If the account isn't eligible, an error will be returned.
+func validateCreateAccount(form *model.AccountCreateRequest, c *config.AccountsConfig) error {
+ if !c.OpenRegistration {
+ return errors.New("registration is not open for this server")
+ }
+
+ if err := util.ValidateUsername(form.Username); err != nil {
+ return err
+ }
+
+ if err := util.ValidateEmail(form.Email); err != nil {
+ return err
+ }
+
+ if err := util.ValidateNewPassword(form.Password); err != nil {
+ return err
+ }
+
+ if !form.Agreement {
+ return errors.New("agreement to terms and conditions not given")
+ }
+
+ if err := util.ValidateLanguage(form.Locale); err != nil {
+ return err
+ }
+
+ if err := util.ValidateSignUpReason(form.Reason, c.ReasonRequired); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/api/client/account/accountcreate_test.go b/internal/api/client/account/accountcreate_test.go
@@ -0,0 +1,388 @@
+// /*
+// GoToSocial
+// Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+// */
+
+package account_test
+
+// import (
+// "bytes"
+// "encoding/json"
+// "fmt"
+// "io"
+// "io/ioutil"
+// "mime/multipart"
+// "net/http"
+// "net/http/httptest"
+// "os"
+// "testing"
+
+// "github.com/gin-gonic/gin"
+// "github.com/google/uuid"
+// "github.com/stretchr/testify/assert"
+// "github.com/stretchr/testify/suite"
+// "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+// "github.com/superseriousbusiness/gotosocial/internal/api/model"
+// "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+// "github.com/superseriousbusiness/gotosocial/testrig"
+
+// "github.com/superseriousbusiness/gotosocial/internal/oauth"
+// "golang.org/x/crypto/bcrypt"
+// )
+
+// type AccountCreateTestSuite struct {
+// AccountStandardTestSuite
+// }
+
+// func (suite *AccountCreateTestSuite) SetupSuite() {
+// suite.testTokens = testrig.NewTestTokens()
+// suite.testClients = testrig.NewTestClients()
+// suite.testApplications = testrig.NewTestApplications()
+// suite.testUsers = testrig.NewTestUsers()
+// suite.testAccounts = testrig.NewTestAccounts()
+// suite.testAttachments = testrig.NewTestAttachments()
+// suite.testStatuses = testrig.NewTestStatuses()
+// }
+
+// func (suite *AccountCreateTestSuite) SetupTest() {
+// suite.config = testrig.NewTestConfig()
+// suite.db = testrig.NewTestDB()
+// suite.storage = testrig.NewTestStorage()
+// suite.log = testrig.NewTestLog()
+// suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+// suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+// suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module)
+// testrig.StandardDBSetup(suite.db)
+// testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+// }
+
+// func (suite *AccountCreateTestSuite) TearDownTest() {
+// testrig.StandardDBTeardown(suite.db)
+// testrig.StandardStorageTeardown(suite.storage)
+// }
+
+// // TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid,
+// // and at the end of it a new user and account should be added into the database.
+// //
+// // This is the handler served at /api/v1/accounts as POST
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
+
+// t := suite.testTokens["local_account_1"]
+// oauthToken := oauth.TokenToOauthToken(t)
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+// ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+
+// // 1. we should have OK from our call to the function
+// suite.EqualValues(http.StatusOK, recorder.Code)
+
+// // 2. we should have a token in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// t := &model.Token{}
+// err = json.Unmarshal(b, t)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
+
+// // check new account
+
+// // 1. we should be able to get the new account from the db
+// acct := >smodel.Account{}
+// err = suite.db.GetLocalAccountByUsername("test_user", acct)
+// assert.NoError(suite.T(), err)
+// assert.NotNil(suite.T(), acct)
+// // 2. reason should be set
+// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason)
+// // 3. display name should be equal to username by default
+// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName)
+// // 4. domain should be nil because this is a local account
+// assert.Nil(suite.T(), nil, acct.Domain)
+// // 5. id should be set and parseable as a uuid
+// assert.NotNil(suite.T(), acct.ID)
+// _, err = uuid.Parse(acct.ID)
+// assert.Nil(suite.T(), err)
+// // 6. private and public key should be set
+// assert.NotNil(suite.T(), acct.PrivateKey)
+// assert.NotNil(suite.T(), acct.PublicKey)
+
+// // check new user
+
+// // 1. we should be able to get the new user from the db
+// usr := >smodel.User{}
+// err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
+// assert.Nil(suite.T(), err)
+// assert.NotNil(suite.T(), usr)
+
+// // 2. user should have account id set to account we got above
+// assert.Equal(suite.T(), acct.ID, usr.AccountID)
+
+// // 3. id should be set and parseable as a uuid
+// assert.NotNil(suite.T(), usr.ID)
+// _, err = uuid.Parse(usr.ID)
+// assert.Nil(suite.T(), err)
+
+// // 4. locale should be equal to what we requested
+// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
+
+// // 5. created by application id should be equal to the app id
+// assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
+
+// // 6. password should be matcheable to what we set above
+// err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
+// assert.Nil(suite.T(), err)
+// }
+
+// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
+// // only registered applications can create accounts, and we don't provide one here.
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+
+// // 1. we should have forbidden from our call to the function because we didn't auth
+// suite.EqualValues(http.StatusForbidden, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+// // set a weak password
+// ctx.Request.Form.Set("password", "weak")
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+// // set an invalid locale
+// ctx.Request.Form.Set("locale", "neverneverland")
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+
+// // close registrations
+// suite.config.AccountsConfig.OpenRegistration = false
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+
+// // remove reason
+// ctx.Request.Form.Set("reason", "")
+
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
+// }
+
+// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
+// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
+// ctx.Request.Form = suite.newUserFormHappyPath
+
+// // remove reason
+// ctx.Request.Form.Set("reason", "just cuz")
+
+// suite.accountModule.AccountCreatePOSTHandler(ctx)
+
+// // check response
+// suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// b, err := ioutil.ReadAll(result.Body)
+// assert.NoError(suite.T(), err)
+// assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
+// }
+
+// /*
+// TESTING: AccountUpdateCredentialsPATCHHandler
+// */
+
+// func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
+
+// // put test local account in db
+// err := suite.db.Put(suite.testAccountLocal)
+// assert.NoError(suite.T(), err)
+
+// // attach avatar to request
+// aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
+// assert.NoError(suite.T(), err)
+// body := &bytes.Buffer{}
+// writer := multipart.NewWriter(body)
+
+// part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
+// assert.NoError(suite.T(), err)
+
+// _, err = io.Copy(part, aviFile)
+// assert.NoError(suite.T(), err)
+
+// err = aviFile.Close()
+// assert.NoError(suite.T(), err)
+
+// err = writer.Close()
+// assert.NoError(suite.T(), err)
+
+// // setup
+// recorder := httptest.NewRecorder()
+// ctx, _ := gin.CreateTestContext(recorder)
+// ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
+// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
+// ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
+// ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
+// suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
+
+// // check response
+
+// // 1. we should have OK because our request was valid
+// suite.EqualValues(http.StatusOK, recorder.Code)
+
+// // 2. we should have an error message in the result body
+// result := recorder.Result()
+// defer result.Body.Close()
+// // TODO: implement proper checks here
+// //
+// // b, err := ioutil.ReadAll(result.Body)
+// // assert.NoError(suite.T(), err)
+// // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
+// }
+
+// func TestAccountCreateTestSuite(t *testing.T) {
+// suite.Run(t, new(AccountCreateTestSuite))
+// }
diff --git a/internal/api/client/account/accountget.go b/internal/api/client/account/accountget.go
@@ -0,0 +1,52 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AccountGETHandler serves the account information held by the server in response to a GET
+// request. It should be served as a GET at /api/v1/accounts/:id.
+//
+// See: https://docs.joinmastodon.org/methods/accounts/
+func (m *Module) AccountGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, false, false, false, false)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
+ return
+ }
+
+ targetAcctID := c.Param(IDKey)
+ if targetAcctID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"})
+ return
+ }
+
+ acctInfo, err := m.processor.AccountGet(authed, targetAcctID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, acctInfo)
+}
diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go
@@ -0,0 +1,71 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
+// It should be served as a PATCH at /api/v1/accounts/update_credentials
+//
+// TODO: this can be optimized massively by building up a picture of what we want the new account
+// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one
+// which is not gonna make the database very happy when lots of requests are going through.
+// This way it would also be safer because the update won't happen until *all* the fields are validated.
+// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss.
+func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) {
+ l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler")
+ authed, err := oauth.Authed(c, true, false, false, true)
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ l.Tracef("retrieved account %+v", authed.Account.ID)
+
+ l.Trace("parsing request form")
+ form := &model.UpdateCredentialsRequest{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // if everything on the form is nil, then nothing has been set and we shouldn't continue
+ if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil {
+ l.Debugf("could not parse form from request")
+ c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"})
+ return
+ }
+
+ acctSensitive, err := m.processor.AccountUpdate(authed, form)
+ if err != nil {
+ l.Debugf("could not update account: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
+ c.JSON(http.StatusOK, acctSensitive)
+}
diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go
@@ -0,0 +1,106 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account_test
+
+import (
+ "bytes"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type AccountUpdateTestSuite struct {
+ AccountStandardTestSuite
+}
+
+func (suite *AccountUpdateTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *AccountUpdateTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *AccountUpdateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
+
+ requestBody, w, err := testrig.CreateMultipartFormData("header", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
+ "display_name": "updated zork display name!!!",
+ "locked": "true",
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.TokenToOauthToken(suite.testTokens["local_account_1"]))
+ ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), bytes.NewReader(requestBody.Bytes())) // the endpoint we're hitting
+ ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
+ suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
+
+ // check response
+
+ // 1. we should have OK because our request was valid
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ // 2. we should have no error message in the result body
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ fmt.Println(string(b))
+
+ // TODO write more assertions allee
+}
+
+func TestAccountUpdateTestSuite(t *testing.T) {
+ suite.Run(t, new(AccountUpdateTestSuite))
+}
diff --git a/internal/api/client/account/accountverify.go b/internal/api/client/account/accountverify.go
@@ -0,0 +1,48 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AccountVerifyGETHandler serves a user's account details to them IF they reached this
+// handler while in possession of a valid token, according to the oauth middleware.
+// It should be served as a GET at /api/v1/accounts/verify_credentials
+func (m *Module) AccountVerifyGETHandler(c *gin.Context) {
+ l := m.log.WithField("func", "accountVerifyGETHandler")
+ authed, err := oauth.Authed(c, true, false, false, true)
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ acctSensitive, err := m.processor.AccountGet(authed, authed.Account.ID)
+ if err != nil {
+ l.Debugf("error getting account from processor: %s", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
+ return
+ }
+
+ c.JSON(http.StatusOK, acctSensitive)
+}
diff --git a/internal/api/client/account/accountverify_test.go b/internal/api/client/account/accountverify_test.go
@@ -0,0 +1,19 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package account_test
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go
@@ -0,0 +1,58 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package admin
+
+import (
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // BasePath is the base API path for this module
+ BasePath = "/api/v1/admin"
+ // EmojiPath is used for posting/deleting custom emojis
+ EmojiPath = BasePath + "/custom_emojis"
+)
+
+// Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc)
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new admin module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *Module) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler)
+ return nil
+}
diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go
@@ -0,0 +1,94 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package admin
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "emojiCreatePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+
+ // make sure we're authed with an admin account
+ authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything*
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+ if !authed.User.Admin {
+ l.Debugf("user %s not an admin", authed.User.ID)
+ c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"})
+ return
+ }
+
+ // extract the media create form from the request context
+ l.Tracef("parsing request form: %+v", c.Request.Form)
+ form := &model.EmojiCreateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ l.Debugf("error parsing form %+v: %s", c.Request.Form, err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)})
+ return
+ }
+
+ // Give the fields on the request form a first pass to make sure the request is superficially valid.
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateEmoji(form); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ mastoEmoji, err := m.processor.AdminEmojiCreate(authed, form)
+ if err != nil {
+ l.Debugf("error creating emoji: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoEmoji)
+}
+
+func validateCreateEmoji(form *model.EmojiCreateRequest) error {
+ // check there actually is an image attached and it's not size 0
+ if form.Image == nil || form.Image.Size == 0 {
+ return errors.New("no emoji given")
+ }
+
+ // a very superficial check to see if the media size limit is exceeded
+ if form.Image.Size > media.EmojiMaxBytes {
+ return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size)
+ }
+
+ return util.ValidateEmojiShortcode(form.Shortcode)
+}
diff --git a/internal/api/client/app/app.go b/internal/api/client/app/app.go
@@ -0,0 +1,54 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package app
+
+import (
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+// BasePath is the base path for this api module
+const BasePath = "/api/v1/apps"
+
+// Module implements the ClientAPIModule interface for requests relating to registering/removing applications
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new auth module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *Module) Route(s router.Router) error {
+ s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler)
+ return nil
+}
diff --git a/internal/api/client/app/app_test.go b/internal/api/client/app/app_test.go
@@ -0,0 +1,21 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package app_test
+
+// TODO: write tests
diff --git a/internal/api/client/app/appcreate.go b/internal/api/client/app/appcreate.go
@@ -0,0 +1,79 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package app
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AppsPOSTHandler should be served at https://example.org/api/v1/apps
+// It is equivalent to: https://docs.joinmastodon.org/methods/apps/
+func (m *Module) AppsPOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "AppsPOSTHandler")
+ l.Trace("entering AppsPOSTHandler")
+
+ authed, err := oauth.Authed(c, false, false, false, false)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
+ return
+ }
+
+ form := &model.ApplicationCreateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
+ return
+ }
+
+ // permitted length for most fields
+ formFieldLen := 64
+ // redirect can be a bit bigger because we probably need to encode data in the redirect uri
+ formRedirectLen := 512
+
+ // check lengths of fields before proceeding so the user can't spam huge entries into the database
+ if len(form.ClientName) > formFieldLen {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", formFieldLen)})
+ return
+ }
+ if len(form.Website) > formFieldLen {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", formFieldLen)})
+ return
+ }
+ if len(form.RedirectURIs) > formRedirectLen {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", formRedirectLen)})
+ return
+ }
+ if len(form.Scopes) > formFieldLen {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", formFieldLen)})
+ return
+ }
+
+ mastoApp, err := m.processor.AppCreate(authed, form)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
+ c.JSON(http.StatusOK, mastoApp)
+}
diff --git a/internal/api/client/auth/auth.go b/internal/api/client/auth/auth.go
@@ -0,0 +1,71 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // AuthSignInPath is the API path for users to sign in through
+ AuthSignInPath = "/auth/sign_in"
+ // OauthTokenPath is the API path to use for granting token requests to users with valid credentials
+ OauthTokenPath = "/oauth/token"
+ // OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user)
+ OauthAuthorizePath = "/oauth/authorize"
+)
+
+// Module implements the ClientAPIModule interface for
+type Module struct {
+ config *config.Config
+ db db.DB
+ server oauth.Server
+ log *logrus.Logger
+}
+
+// New returns a new auth module
+func New(config *config.Config, db db.DB, server oauth.Server, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ db: db,
+ server: server,
+ log: log,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *Module) Route(s router.Router) error {
+ s.AttachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler)
+ s.AttachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler)
+
+ s.AttachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler)
+
+ s.AttachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler)
+ s.AttachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler)
+
+ s.AttachMiddleware(m.OauthTokenMiddleware)
+ return nil
+}
diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go
@@ -0,0 +1,166 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/google/uuid"
+ "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/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type AuthTestSuite struct {
+ suite.Suite
+ oauthServer oauth.Server
+ db db.DB
+ testAccount *gtsmodel.Account
+ testApplication *gtsmodel.Application
+ testUser *gtsmodel.User
+ testClient *oauth.Client
+ config *config.Config
+}
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *AuthTestSuite) SetupSuite() {
+ c := config.Empty()
+ // we're running on localhost without https so set the protocol to http
+ c.Protocol = "http"
+ // just for testing
+ c.Host = "localhost:8080"
+ // because go tests are run within the test package directory, we need to fiddle with the templateconfig
+ // basedir in a way that we wouldn't normally have to do when running the binary, in order to make
+ // the templates actually load
+ c.TemplateConfig.BaseDir = "../../../web/template/"
+ c.DBConfig = &config.DBConfig{
+ Type: "postgres",
+ Address: "localhost",
+ Port: 5432,
+ User: "postgres",
+ Password: "postgres",
+ Database: "postgres",
+ ApplicationName: "gotosocial",
+ }
+ suite.config = c
+
+ encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
+ if err != nil {
+ logrus.Panicf("error encrypting user pass: %s", err)
+ }
+
+ acctID := uuid.NewString()
+
+ suite.testAccount = >smodel.Account{
+ ID: acctID,
+ Username: "test_user",
+ }
+ suite.testUser = >smodel.User{
+ EncryptedPassword: string(encryptedPassword),
+ Email: "user@example.org",
+ AccountID: acctID,
+ }
+ suite.testClient = &oauth.Client{
+ ID: "a-known-client-id",
+ Secret: "some-secret",
+ Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host),
+ }
+ suite.testApplication = >smodel.Application{
+ Name: "a test application",
+ Website: "https://some-application-website.com",
+ RedirectURI: "http://localhost:8080",
+ ClientID: "a-known-client-id",
+ ClientSecret: "some-secret",
+ Scopes: "read",
+ VapidKey: uuid.NewString(),
+ }
+}
+
+// SetupTest creates a postgres connection and creates the oauth_clients table before each test
+func (suite *AuthTestSuite) SetupTest() {
+
+ log := logrus.New()
+ log.SetLevel(logrus.TraceLevel)
+ db, err := db.NewPostgresService(context.Background(), suite.config, log)
+ if err != nil {
+ logrus.Panicf("error creating database connection: %s", err)
+ }
+
+ suite.db = db
+
+ models := []interface{}{
+ &oauth.Client{},
+ &oauth.Token{},
+ >smodel.User{},
+ >smodel.Account{},
+ >smodel.Application{},
+ }
+
+ for _, m := range models {
+ if err := suite.db.CreateTable(m); err != nil {
+ logrus.Panicf("db connection error: %s", err)
+ }
+ }
+
+ suite.oauthServer = oauth.New(suite.db, log)
+
+ if err := suite.db.Put(suite.testAccount); err != nil {
+ logrus.Panicf("could not insert test account into db: %s", err)
+ }
+ if err := suite.db.Put(suite.testUser); err != nil {
+ logrus.Panicf("could not insert test user into db: %s", err)
+ }
+ if err := suite.db.Put(suite.testClient); err != nil {
+ logrus.Panicf("could not insert test client into db: %s", err)
+ }
+ if err := suite.db.Put(suite.testApplication); err != nil {
+ logrus.Panicf("could not insert test application into db: %s", err)
+ }
+
+}
+
+// TearDownTest drops the oauth_clients table and closes the pg connection after each test
+func (suite *AuthTestSuite) TearDownTest() {
+ models := []interface{}{
+ &oauth.Client{},
+ &oauth.Token{},
+ >smodel.User{},
+ >smodel.Account{},
+ >smodel.Application{},
+ }
+ for _, m := range models {
+ if err := suite.db.DropTable(m); err != nil {
+ logrus.Panicf("error dropping table: %s", err)
+ }
+ }
+ if err := suite.db.Stop(context.Background()); err != nil {
+ logrus.Panicf("error closing db connection: %s", err)
+ }
+ suite.db = nil
+}
+
+func TestAuthTestSuite(t *testing.T) {
+ suite.Run(t, new(AuthTestSuite))
+}
diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go
@@ -0,0 +1,204 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize
+// The idea here is to present an oauth authorize page to the user, with a button
+// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
+func (m *Module) AuthorizeGETHandler(c *gin.Context) {
+ l := m.log.WithField("func", "AuthorizeGETHandler")
+ s := sessions.Default(c)
+
+ // UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow
+ // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page.
+ userID, ok := s.Get("userid").(string)
+ if !ok || userID == "" {
+ l.Trace("userid was empty, parsing form then redirecting to sign in page")
+ if err := parseAuthForm(c, l); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ } else {
+ c.Redirect(http.StatusFound, AuthSignInPath)
+ }
+ return
+ }
+
+ // We can use the client_id on the session to retrieve info about the app associated with the client_id
+ clientID, ok := s.Get("client_id").(string)
+ if !ok || clientID == "" {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"})
+ return
+ }
+ app := >smodel.Application{
+ ClientID: clientID,
+ }
+ if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)})
+ return
+ }
+
+ // we can also use the userid of the user to fetch their username from the db to greet them nicely <3
+ user := >smodel.User{
+ ID: userID,
+ }
+ if err := m.db.GetByID(user.ID, user); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ acct := >smodel.Account{
+ ID: user.AccountID,
+ }
+
+ if err := m.db.GetByID(acct.ID, acct); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Finally we should also get the redirect and scope of this particular request, as stored in the session.
+ redirect, ok := s.Get("redirect_uri").(string)
+ if !ok || redirect == "" {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"})
+ return
+ }
+ scope, ok := s.Get("scope").(string)
+ if !ok || scope == "" {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"})
+ return
+ }
+
+ // the authorize template will display a form to the user where they can get some information
+ // about the app that's trying to authorize, and the scope of the request.
+ // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler
+ l.Trace("serving authorize html")
+ c.HTML(http.StatusOK, "authorize.tmpl", gin.H{
+ "appname": app.Name,
+ "appwebsite": app.Website,
+ "redirect": redirect,
+ "scope": scope,
+ "user": acct.Username,
+ })
+}
+
+// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize
+// At this point we assume that the user has A) logged in and B) accepted that the app should act for them,
+// so we should proceed with the authentication flow and generate an oauth token for them if we can.
+// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
+func (m *Module) AuthorizePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "AuthorizePOSTHandler")
+ s := sessions.Default(c)
+
+ // At this point we know the user has said 'yes' to allowing the application and oauth client
+ // work for them, so we can set the
+
+ // We need to retrieve the original form submitted to the authorizeGEThandler, and
+ // recreate it on the request so that it can be used further by the oauth2 library.
+ // So first fetch all the values from the session.
+ forceLogin, ok := s.Get("force_login").(string)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"})
+ return
+ }
+ responseType, ok := s.Get("response_type").(string)
+ if !ok || responseType == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"})
+ return
+ }
+ clientID, ok := s.Get("client_id").(string)
+ if !ok || clientID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"})
+ return
+ }
+ redirectURI, ok := s.Get("redirect_uri").(string)
+ if !ok || redirectURI == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"})
+ return
+ }
+ scope, ok := s.Get("scope").(string)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"})
+ return
+ }
+ userID, ok := s.Get("userid").(string)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"})
+ return
+ }
+ // we're done with the session so we can clear it now
+ s.Clear()
+
+ // now set the values on the request
+ values := url.Values{}
+ values.Set("force_login", forceLogin)
+ values.Set("response_type", responseType)
+ values.Set("client_id", clientID)
+ values.Set("redirect_uri", redirectURI)
+ values.Set("scope", scope)
+ values.Set("userid", userID)
+ c.Request.Form = values
+ l.Tracef("values on request set to %+v", c.Request.Form)
+
+ // and proceed with authorization using the oauth2 library
+ if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ }
+}
+
+// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores
+// the values in the form into the session.
+func parseAuthForm(c *gin.Context, l *logrus.Entry) error {
+ s := sessions.Default(c)
+
+ // first make sure they've filled out the authorize form with the required values
+ form := &model.OAuthAuthorize{}
+ if err := c.ShouldBind(form); err != nil {
+ return err
+ }
+ l.Tracef("parsed form: %+v", form)
+
+ // these fields are *required* so check 'em
+ if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" {
+ return errors.New("missing one of: response_type, client_id or redirect_uri")
+ }
+
+ // set default scope to read
+ if form.Scope == "" {
+ form.Scope = "read"
+ }
+
+ // save these values from the form so we can use them elsewhere in the session
+ s.Set("force_login", form.ForceLogin)
+ s.Set("response_type", form.ResponseType)
+ s.Set("client_id", form.ClientID)
+ s.Set("redirect_uri", form.RedirectURI)
+ s.Set("scope", form.Scope)
+ return s.Save()
+}
diff --git a/internal/api/client/auth/middleware.go b/internal/api/client/auth/middleware.go
@@ -0,0 +1,76 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// OauthTokenMiddleware checks if the client has presented a valid oauth Bearer token.
+// If so, it will check the User that the token belongs to, and set that in the context of
+// the request. Then, it will look up the account for that user, and set that in the request too.
+// If user or account can't be found, then the handler won't *fail*, in case the server wants to allow
+// public requests that don't have a Bearer token set (eg., for public instance information and so on).
+func (m *Module) OauthTokenMiddleware(c *gin.Context) {
+ l := m.log.WithField("func", "OauthTokenMiddleware")
+ l.Trace("entering OauthTokenMiddleware")
+
+ ti, err := m.server.ValidationBearerToken(c.Request)
+ if err != nil {
+ l.Trace("no valid token presented: continuing with unauthenticated request")
+ return
+ }
+ c.Set(oauth.SessionAuthorizedToken, ti)
+ l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedToken, ti)
+
+ // check for user-level token
+ if uid := ti.GetUserID(); uid != "" {
+ l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope())
+
+ // fetch user's and account for this user id
+ user := >smodel.User{}
+ if err := m.db.GetByID(uid, user); err != nil || user == nil {
+ l.Warnf("no user found for validated uid %s", uid)
+ return
+ }
+ c.Set(oauth.SessionAuthorizedUser, user)
+ l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user)
+
+ acct := >smodel.Account{}
+ if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil {
+ l.Warnf("no account found for validated user %s", uid)
+ return
+ }
+ c.Set(oauth.SessionAuthorizedAccount, acct)
+ l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct)
+ }
+
+ // check for application token
+ if cid := ti.GetClientID(); cid != "" {
+ l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope())
+ app := >smodel.Application{}
+ if err := m.db.GetWhere("client_id", cid, app); err != nil {
+ l.Tracef("no app found for client %s", cid)
+ }
+ c.Set(oauth.SessionAuthorizedApplication, app)
+ l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app)
+ }
+}
diff --git a/internal/api/client/auth/signin.go b/internal/api/client/auth/signin.go
@@ -0,0 +1,116 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package auth
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// login just wraps a form-submitted username (we want an email) and password
+type login struct {
+ Email string `form:"username"`
+ Password string `form:"password"`
+}
+
+// SignInGETHandler should be served at https://example.org/auth/sign_in.
+// The idea is to present a sign in page to the user, where they can enter their username and password.
+// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler
+func (m *Module) SignInGETHandler(c *gin.Context) {
+ m.log.WithField("func", "SignInGETHandler").Trace("serving sign in html")
+ c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{})
+}
+
+// SignInPOSTHandler should be served at https://example.org/auth/sign_in.
+// The idea is to present a sign in page to the user, where they can enter their username and password.
+// The handler will then redirect to the auth handler served at /auth
+func (m *Module) SignInPOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "SignInPOSTHandler")
+ s := sessions.Default(c)
+ form := &login{}
+ if err := c.ShouldBind(form); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ l.Tracef("parsed form: %+v", form)
+
+ userid, err := m.ValidatePassword(form.Email, form.Password)
+ if err != nil {
+ c.String(http.StatusForbidden, err.Error())
+ return
+ }
+
+ s.Set("userid", userid)
+ if err := s.Save(); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ l.Trace("redirecting to auth page")
+ c.Redirect(http.StatusFound, OauthAuthorizePath)
+}
+
+// ValidatePassword takes an email address and a password.
+// The goal is to authenticate the password against the one for that email
+// address stored in the database. If OK, we return the userid (a uuid) for that user,
+// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db.
+func (m *Module) ValidatePassword(email string, password string) (userid string, err error) {
+ l := m.log.WithField("func", "ValidatePassword")
+
+ // make sure an email/password was provided and bail if not
+ if email == "" || password == "" {
+ l.Debug("email or password was not provided")
+ return incorrectPassword()
+ }
+
+ // first we select the user from the database based on email address, bail if no user found for that email
+ gtsUser := >smodel.User{}
+
+ if err := m.db.GetWhere("email", email, gtsUser); err != nil {
+ l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
+ return incorrectPassword()
+ }
+
+ // make sure a password is actually set and bail if not
+ if gtsUser.EncryptedPassword == "" {
+ l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email)
+ return incorrectPassword()
+ }
+
+ // compare the provided password with the encrypted one from the db, bail if they don't match
+ if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil {
+ l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err)
+ return incorrectPassword()
+ }
+
+ // If we've made it this far the email/password is correct, so we can just return the id of the user.
+ userid = gtsUser.ID
+ l.Tracef("returning (%s, %s)", userid, err)
+ return
+}
+
+// incorrectPassword is just a little helper function to use in the ValidatePassword function
+func incorrectPassword() (string, error) {
+ return "", errors.New("password/email combination was incorrect")
+}
diff --git a/internal/apimodule/auth/token.go b/internal/api/client/auth/token.go
diff --git a/internal/api/client/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go
@@ -0,0 +1,82 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package fileserver
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // AccountIDKey is the url key for account id (an account uuid)
+ AccountIDKey = "account_id"
+ // MediaTypeKey is the url key for media type (usually something like attachment or header etc)
+ MediaTypeKey = "media_type"
+ // MediaSizeKey is the url key for the desired media size--original/small/static
+ MediaSizeKey = "media_size"
+ // FileNameKey is the actual filename being sought. Will usually be a UUID then something like .jpeg
+ FileNameKey = "file_name"
+)
+
+// FileServer implements the RESTAPIModule interface.
+// The goal here is to serve requested media files if the gotosocial server is configured to use local storage.
+type FileServer struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+ storageBase string
+}
+
+// New returns a new fileServer module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &FileServer{
+ config: config,
+ processor: processor,
+ log: log,
+ storageBase: config.StorageConfig.ServeBasePath,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *FileServer) Route(s router.Router) error {
+ s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile)
+ return nil
+}
+
+// CreateTables populates necessary tables in the given DB
+func (m *FileServer) CreateTables(db db.DB) error {
+ models := []interface{}{
+ >smodel.MediaAttachment{},
+ }
+
+ for _, m := range models {
+ if err := db.CreateTable(m); err != nil {
+ return fmt.Errorf("error creating table: %s", err)
+ }
+ }
+ return nil
+}
diff --git a/internal/api/client/fileserver/servefile.go b/internal/api/client/fileserver/servefile.go
@@ -0,0 +1,94 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package fileserver
+
+import (
+ "bytes"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
+//
+// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
+// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
+func (m *FileServer) ServeFile(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "ServeFile",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Trace("received request")
+
+ authed, err := oauth.Authed(c, false, false, false, false)
+ if err != nil {
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
+ // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
+ // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
+ accountID := c.Param(AccountIDKey)
+ if accountID == "" {
+ l.Debug("missing accountID from request")
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ mediaType := c.Param(MediaTypeKey)
+ if mediaType == "" {
+ l.Debug("missing mediaType from request")
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ mediaSize := c.Param(MediaSizeKey)
+ if mediaSize == "" {
+ l.Debug("missing mediaSize from request")
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ fileName := c.Param(FileNameKey)
+ if fileName == "" {
+ l.Debug("missing fileName from request")
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ content, err := m.processor.MediaGet(authed, &model.GetContentRequestForm{
+ AccountID: accountID,
+ MediaType: mediaType,
+ MediaSize: mediaSize,
+ FileName: fileName,
+ })
+ if err != nil {
+ l.Debug(err)
+ c.String(http.StatusNotFound, "404 page not found")
+ return
+ }
+
+ c.DataFromReader(http.StatusOK, content.ContentLength, content.ContentType, bytes.NewReader(content.Content), nil)
+}
diff --git a/internal/api/client/fileserver/servefile_test.go b/internal/api/client/fileserver/servefile_test.go
@@ -0,0 +1,163 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package fileserver_test
+
+import (
+ "context"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ServeFileTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ federator federation.Federator
+ tc typeutils.TypeConverter
+ processor message.Processor
+ mediaHandler media.Handler
+ oauthServer oauth.Server
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+
+ // item being tested
+ fileServer *fileserver.FileServer
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+func (suite *ServeFileTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.tc = testrig.NewTestTypeConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+
+ // setup module being tested
+ suite.fileServer = fileserver.New(suite.config, suite.processor, suite.log).(*fileserver.FileServer)
+}
+
+func (suite *ServeFileTestSuite) TearDownSuite() {
+ if err := suite.db.Stop(context.Background()); err != nil {
+ logrus.Panicf("error closing db connection: %s", err)
+ }
+}
+
+func (suite *ServeFileTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+}
+
+func (suite *ServeFileTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() {
+ targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"]
+ assert.True(suite.T(), ok)
+ assert.NotNil(suite.T(), targetAttachment)
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil)
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the ServeFile function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: fileserver.AccountIDKey,
+ Value: targetAttachment.AccountID,
+ },
+ gin.Param{
+ Key: fileserver.MediaTypeKey,
+ Value: string(media.Attachment),
+ },
+ gin.Param{
+ Key: fileserver.MediaSizeKey,
+ Value: string(media.Original),
+ },
+ gin.Param{
+ Key: fileserver.FileNameKey,
+ Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID),
+ },
+ }
+
+ // call the function we're testing and check status code
+ suite.fileServer.ServeFile(ctx)
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ b, err := ioutil.ReadAll(recorder.Body)
+ assert.NoError(suite.T(), err)
+ assert.NotNil(suite.T(), b)
+
+ fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path)
+ assert.NoError(suite.T(), err)
+ assert.NotNil(suite.T(), fileInStorage)
+ assert.Equal(suite.T(), b, fileInStorage)
+}
+
+func TestServeFileTestSuite(t *testing.T) {
+ suite.Run(t, new(ServeFileTestSuite))
+}
diff --git a/internal/api/client/media/media.go b/internal/api/client/media/media.go
@@ -0,0 +1,71 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+// BasePath is the base API path for making media requests
+const BasePath = "/api/v1/media"
+
+// Module implements the ClientAPIModule interface for media
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new auth module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *Module) Route(s router.Router) error {
+ s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler)
+ return nil
+}
+
+// CreateTables populates necessary tables in the given DB
+func (m *Module) CreateTables(db db.DB) error {
+ models := []interface{}{
+ >smodel.MediaAttachment{},
+ }
+
+ for _, m := range models {
+ if err := db.CreateTable(m); err != nil {
+ return fmt.Errorf("error creating table: %s", err)
+ }
+ }
+ return nil
+}
diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go
@@ -0,0 +1,91 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// MediaCreatePOSTHandler handles requests to create/upload media attachments
+func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.Authed(c, true, true, true, true) // posting new media is serious business so we want *everything*
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ // extract the media create form from the request context
+ l.Tracef("parsing request form: %s", c.Request.Form)
+ form := &model.AttachmentRequest{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+ return
+ }
+
+ // Give the fields on the request form a first pass to make sure the request is superficially valid.
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ mastoAttachment, err := m.processor.MediaCreate(authed, form)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusAccepted, mastoAttachment)
+}
+
+func validateCreateMedia(form *model.AttachmentRequest, config *config.MediaConfig) error {
+ // check there actually is a file attached and it's not size 0
+ if form.File == nil || form.File.Size == 0 {
+ return errors.New("no attachment given")
+ }
+
+ // a very superficial check to see if no size limits are exceeded
+ // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
+ maxSize := config.MaxVideoSize
+ if config.MaxImageSize > maxSize {
+ maxSize = config.MaxImageSize
+ }
+ if form.File.Size > int64(maxSize) {
+ return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
+ }
+
+ if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars {
+ return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description))
+ }
+
+ // TODO: validate focus here
+
+ return nil
+}
diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go
@@ -0,0 +1,200 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package media_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type MediaCreateTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ federator federation.Federator
+ tc typeutils.TypeConverter
+ mediaHandler media.Handler
+ oauthServer oauth.Server
+ processor message.Processor
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+
+ // item being tested
+ mediaModule *mediamodule.Module
+}
+
+/*
+ TEST INFRASTRUCTURE
+*/
+
+func (suite *MediaCreateTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.tc = testrig.NewTestTypeConverter(suite.db)
+ suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
+ suite.oauthServer = testrig.NewTestOauthServer(suite.db)
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+
+ // setup module being tested
+ suite.mediaModule = mediamodule.New(suite.config, suite.processor, suite.log).(*mediamodule.Module)
+}
+
+func (suite *MediaCreateTestSuite) TearDownSuite() {
+ if err := suite.db.Stop(context.Background()); err != nil {
+ logrus.Panicf("error closing db connection: %s", err)
+ }
+}
+
+func (suite *MediaCreateTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+}
+
+func (suite *MediaCreateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+/*
+ ACTUAL TESTS
+*/
+
+func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() {
+
+ // set up the context for the request
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+
+ // see what's in storage *before* the request
+ storageKeysBeforeRequest, err := suite.storage.ListKeys()
+ if err != nil {
+ panic(err)
+ }
+
+ // create the request
+ buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
+ "description": "this is a test image -- a cool background from somewhere",
+ "focus": "-0.5,0.5",
+ })
+ if err != nil {
+ panic(err)
+ }
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting
+ ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
+
+ // do the actual request
+ suite.mediaModule.MediaCreatePOSTHandler(ctx)
+
+ // check what's in storage *after* the request
+ storageKeysAfterRequest, err := suite.storage.ListKeys()
+ if err != nil {
+ panic(err)
+ }
+
+ // check response
+ suite.EqualValues(http.StatusAccepted, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ fmt.Println(string(b))
+
+ attachmentReply := &model.Attachment{}
+ err = json.Unmarshal(b, attachmentReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description)
+ assert.Equal(suite.T(), "image", attachmentReply.Type)
+ assert.EqualValues(suite.T(), model.MediaMeta{
+ Original: model.MediaDimensions{
+ Width: 1920,
+ Height: 1080,
+ Size: "1920x1080",
+ Aspect: 1.7777778,
+ },
+ Small: model.MediaDimensions{
+ Width: 256,
+ Height: 144,
+ Size: "256x144",
+ Aspect: 1.7777778,
+ },
+ Focus: model.MediaFocus{
+ X: -0.5,
+ Y: 0.5,
+ },
+ }, attachmentReply.Meta)
+ assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash)
+ assert.NotEmpty(suite.T(), attachmentReply.ID)
+ assert.NotEmpty(suite.T(), attachmentReply.URL)
+ assert.NotEmpty(suite.T(), attachmentReply.PreviewURL)
+ assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail
+}
+
+func TestMediaCreateTestSuite(t *testing.T) {
+ suite.Run(t, new(MediaCreateTestSuite))
+}
diff --git a/internal/api/client/status/status.go b/internal/api/client/status/status.go
@@ -0,0 +1,118 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+const (
+ // IDKey is for status UUIDs
+ IDKey = "id"
+ // BasePath is the base path for serving the status API
+ BasePath = "/api/v1/statuses"
+ // BasePathWithID is just the base path with the ID key in it.
+ // Use this anywhere you need to know the ID of the status being queried.
+ BasePathWithID = BasePath + "/:" + IDKey
+
+ // ContextPath is used for fetching context of posts
+ ContextPath = BasePathWithID + "/context"
+
+ // FavouritedPath is for seeing who's faved a given status
+ FavouritedPath = BasePathWithID + "/favourited_by"
+ // FavouritePath is for posting a fave on a status
+ FavouritePath = BasePathWithID + "/favourite"
+ // UnfavouritePath is for removing a fave from a status
+ UnfavouritePath = BasePathWithID + "/unfavourite"
+
+ // RebloggedPath is for seeing who's boosted a given status
+ RebloggedPath = BasePathWithID + "/reblogged_by"
+ // ReblogPath is for boosting/reblogging a given status
+ ReblogPath = BasePathWithID + "/reblog"
+ // UnreblogPath is for undoing a boost/reblog of a given status
+ UnreblogPath = BasePathWithID + "/unreblog"
+
+ // BookmarkPath is for creating a bookmark on a given status
+ BookmarkPath = BasePathWithID + "/bookmark"
+ // UnbookmarkPath is for removing a bookmark from a given status
+ UnbookmarkPath = BasePathWithID + "/unbookmark"
+
+ // MutePath is for muting a given status so that notifications will no longer be received about it.
+ MutePath = BasePathWithID + "/mute"
+ // UnmutePath is for undoing an existing mute
+ UnmutePath = BasePathWithID + "/unmute"
+
+ // PinPath is for pinning a status to an account profile so that it's the first thing people see
+ PinPath = BasePathWithID + "/pin"
+ // UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy
+ UnpinPath = BasePathWithID + "/unpin"
+)
+
+// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new account module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route attaches all routes from this module to the given router
+func (m *Module) Route(r router.Router) error {
+ r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
+ r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
+
+ r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
+ r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler)
+
+ r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
+ return nil
+}
+
+// muxHandler is a little workaround to overcome the limitations of Gin
+func (m *Module) muxHandler(c *gin.Context) {
+ m.log.Debug("entering mux handler")
+ ru := c.Request.RequestURI
+
+ switch c.Request.Method {
+ case http.MethodGet:
+ if strings.HasPrefix(ru, ContextPath) {
+ // TODO
+ } else if strings.HasPrefix(ru, FavouritedPath) {
+ m.StatusFavedByGETHandler(c)
+ } else {
+ m.StatusGETHandler(c)
+ }
+ }
+}
diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go
@@ -0,0 +1,58 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type StatusStandardTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ tc typeutils.TypeConverter
+ federator federation.Federator
+ processor message.Processor
+ storage storage.Storage
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+
+ // module being tested
+ statusModule *status.Module
+}
diff --git a/internal/api/client/status/statuscreate.go b/internal/api/client/status/statuscreate.go
@@ -0,0 +1,130 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// StatusCreatePOSTHandler deals with the creation of new statuses
+func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
+ l := m.log.WithField("func", "statusCreatePOSTHandler")
+ authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything*
+ if err != nil {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ return
+ }
+
+ // First check this user/account is permitted to post new statuses.
+ // There's no point continuing otherwise.
+ if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+ l.Debugf("couldn't auth: %s", err)
+ c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
+ return
+ }
+
+ // extract the status create form from the request context
+ l.Tracef("parsing request form: %s", c.Request.Form)
+ form := &model.AdvancedStatusCreateForm{}
+ if err := c.ShouldBind(form); err != nil || form == nil {
+ l.Debugf("could not parse form from request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
+ return
+ }
+
+ // Give the fields on the request form a first pass to make sure the request is superficially valid.
+ l.Tracef("validating form %+v", form)
+ if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
+ l.Debugf("error validating form: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusCreate(authed, form)
+ if err != nil {
+ l.Debugf("error processing status create: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
+
+func validateCreateStatus(form *model.AdvancedStatusCreateForm, config *config.StatusesConfig) error {
+ // validate that, structurally, we have a valid status/post
+ if form.Status == "" && form.MediaIDs == nil && form.Poll == nil {
+ return errors.New("no status, media, or poll provided")
+ }
+
+ if form.MediaIDs != nil && form.Poll != nil {
+ return errors.New("can't post media + poll in same status")
+ }
+
+ // validate status
+ if form.Status != "" {
+ if len(form.Status) > config.MaxChars {
+ return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars)
+ }
+ }
+
+ // validate media attachments
+ if len(form.MediaIDs) > config.MaxMediaFiles {
+ return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles)
+ }
+
+ // validate poll
+ if form.Poll != nil {
+ if form.Poll.Options == nil {
+ return errors.New("poll with no options")
+ }
+ if len(form.Poll.Options) > config.PollMaxOptions {
+ return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions)
+ }
+ for _, p := range form.Poll.Options {
+ if len(p) > config.PollOptionMaxChars {
+ return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars)
+ }
+ }
+ }
+
+ // validate spoiler text/cw
+ if form.SpoilerText != "" {
+ if len(form.SpoilerText) > config.CWMaxChars {
+ return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars)
+ }
+ }
+
+ // validate post language
+ if form.Language != "" {
+ if err := util.ValidateLanguage(form.Language); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/internal/api/client/status/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go
@@ -0,0 +1,297 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusCreateTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusCreateTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusCreateTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusCreateTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// Post a new status with some custom visibility settings
+func (suite *StatusCreateTestSuite) TestPostNewStatus() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"this is a brand new status! #helloworld"},
+ "spoiler_text": {"hello hello"},
+ "sensitive": {"true"},
+ "visibility_advanced": {"mutuals_only"},
+ "likeable": {"false"},
+ "replyable": {"false"},
+ "federated": {"false"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+
+ // 1. we should have OK from our call to the function
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.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(), model.VisibilityPrivate, statusReply.Visibility)
+ assert.Len(suite.T(), statusReply.Tags, 1)
+ assert.Equal(suite.T(), model.Tag{
+ Name: "helloworld",
+ URL: "http://localhost:8080/tags/helloworld",
+ }, statusReply.Tags[0])
+
+ gtsTag := >smodel.Tag{}
+ err = suite.db.GetWhere("name", "helloworld", gtsTag)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
+}
+
+func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content)
+
+ assert.Len(suite.T(), statusReply.Emojis, 1)
+ mastoEmoji := statusReply.Emojis[0]
+ gtsEmoji := testrig.NewTestEmojis()["rainbow"]
+
+ assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode)
+ assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL)
+ assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL)
+}
+
+// Try to reply to a status that doesn't exist
+func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"this is a reply to a status that doesn't exist"},
+ "spoiler_text": {"don't open cuz it won't work"},
+ "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+
+ suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
+}
+
+// Post a reply to the status of a local user that allows replies.
+func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)},
+ "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+ assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID)
+ assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID)
+ assert.Len(suite.T(), statusReply.Mentions, 1)
+}
+
+// Take a media file which is currently not associated with a status, and attach it to a new status.
+func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
+ ctx.Request.Form = url.Values{
+ "status": {"here's an image attachment"},
+ "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"},
+ }
+ suite.statusModule.StatusCreatePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ fmt.Println(string(b))
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), "", statusReply.SpoilerText)
+ assert.Equal(suite.T(), "here's an image attachment", statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+
+ // there should be one media attachment
+ assert.Len(suite.T(), statusReply.MediaAttachments, 1)
+
+ // get the updated media attachment from the database
+ gtsAttachment := >smodel.MediaAttachment{}
+ err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment)
+ assert.NoError(suite.T(), err)
+
+ // convert it to a masto attachment
+ gtsAttachmentAsMasto, err := suite.tc.AttachmentToMasto(gtsAttachment)
+ assert.NoError(suite.T(), err)
+
+ // compare it with what we have now
+ assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto)
+
+ // the status id of the attachment should now be set to the id of the status we just created
+ assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID)
+}
+
+func TestStatusCreateTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusCreateTestSuite))
+}
diff --git a/internal/api/client/status/statusdelete.go b/internal/api/client/status/statusdelete.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusDELETEHandler verifies and handles deletion of a status
+func (m *Module) StatusDELETEHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusDELETEHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't delete status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusDelete(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status delete: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusfave.go b/internal/api/client/status/statusfave.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavePOSTHandler handles fave requests against a given status ID
+func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusFavePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't fave status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusFave(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status fave: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusfave_test.go b/internal/api/client/status/statusfave_test.go
@@ -0,0 +1,158 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFaveTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusFaveTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusFaveTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusFaveTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// fave a status
+func (suite *StatusFaveTestSuite) TestPostFave() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.True(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+ assert.True(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
+}
+
+// try to fave a status that's not faveable
+func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+ assert.Equal(suite.T(), `{"error":"bad request"}`, string(b))
+}
+
+func TestStatusFaveTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusFaveTestSuite))
+}
diff --git a/internal/api/client/status/statusfavedby.go b/internal/api/client/status/statusfavedby.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status
+func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "statusGETHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Errorf("error authing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoAccounts, err := m.processor.StatusFavedBy(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoAccounts)
+}
diff --git a/internal/api/client/status/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go
@@ -0,0 +1,114 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusFavedByTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusFavedByTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusFavedByTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusFavedByTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
+ t := suite.testTokens["local_account_2"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusFavedByGETHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ accts := []model.Account{}
+ err = json.Unmarshal(b, &accts)
+ assert.NoError(suite.T(), err)
+
+ assert.Len(suite.T(), accts, 1)
+ assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username)
+}
+
+func TestStatusFavedByTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusFavedByTestSuite))
+}
diff --git a/internal/api/client/status/statusget.go b/internal/api/client/status/statusget.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusGETHandler is for handling requests to just get one status based on its ID
+func (m *Module) 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")
+
+ authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Errorf("error authing status faved by request: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusGet(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status get: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusget_test.go b/internal/api/client/status/statusget_test.go
@@ -0,0 +1,117 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusGetTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusGetTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusGetTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusGetTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// 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 := &mastotypes.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(), mastotypes.VisibilityPrivate, statusReply.Visibility)
+ // assert.Len(suite.T(), statusReply.Tags, 1)
+ // assert.Equal(suite.T(), mastotypes.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/api/client/status/statusunfave.go b/internal/api/client/status/statusunfave.go
@@ -0,0 +1,60 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID
+func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "StatusUnfavePOSTHandler",
+ "request_uri": c.Request.RequestURI,
+ "user_agent": c.Request.UserAgent(),
+ "origin_ip": c.ClientIP(),
+ })
+ l.Debugf("entering function")
+
+ authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
+ if err != nil {
+ l.Debug("not authed so can't unfave status")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
+ return
+ }
+
+ targetStatusID := c.Param(IDKey)
+ if targetStatusID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
+ return
+ }
+
+ mastoStatus, err := m.processor.StatusUnfave(authed, targetStatusID)
+ if err != nil {
+ l.Debugf("error processing status unfave: %s", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
+ return
+ }
+
+ c.JSON(http.StatusOK, mastoStatus)
+}
diff --git a/internal/api/client/status/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go
@@ -0,0 +1,170 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package status_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusUnfaveTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusUnfaveTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *StatusUnfaveTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *StatusUnfaveTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+// unfave a status
+func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // this is the status we wanna unfave: in the testrig it's already faved by this account
+ targetStatus := suite.testStatuses["admin_account_status_1"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnfavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.False(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+ assert.False(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
+}
+
+// try to unfave a status that's already not faved
+func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {
+
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.TokenToOauthToken(t)
+
+ // this is the status we wanna unfave: in the testrig it's not faved by this account
+ targetStatus := suite.testStatuses["admin_account_status_2"]
+
+ // setup
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: status.IDKey,
+ Value: targetStatus.ID,
+ },
+ }
+
+ suite.statusModule.StatusUnfavePOSTHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ statusReply := &model.Status{}
+ err = json.Unmarshal(b, statusReply)
+ assert.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
+ assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
+ assert.True(suite.T(), statusReply.Sensitive)
+ assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility)
+ assert.False(suite.T(), statusReply.Favourited)
+ assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
+}
+
+func TestStatusUnfaveTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusUnfaveTestSuite))
+}
diff --git a/internal/api/model/account.go b/internal/api/model/account.go
@@ -0,0 +1,136 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+import (
+ "mime/multipart"
+ "net"
+)
+
+// Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/
+type Account struct {
+ // The account id
+ ID string `json:"id"`
+ // The username of the account, not including domain.
+ Username string `json:"username"`
+ // The Webfinger account URI. Equal to username for local users, or username@domain for remote users.
+ Acct string `json:"acct"`
+ // The profile's display name.
+ DisplayName string `json:"display_name"`
+ // Whether the account manually approves follow requests.
+ Locked bool `json:"locked"`
+ // Whether the account has opted into discovery features such as the profile directory.
+ Discoverable bool `json:"discoverable,omitempty"`
+ // A presentational flag. Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot.
+ Bot bool `json:"bot"`
+ // When the account was created. (ISO 8601 Datetime)
+ CreatedAt string `json:"created_at"`
+ // The profile's bio / description.
+ Note string `json:"note"`
+ // The location of the user's profile page.
+ URL string `json:"url"`
+ // An image icon that is shown next to statuses and in the profile.
+ Avatar string `json:"avatar"`
+ // A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF.
+ AvatarStatic string `json:"avatar_static"`
+ // An image banner that is shown above the profile and in profile cards.
+ Header string `json:"header"`
+ // A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF.
+ HeaderStatic string `json:"header_static"`
+ // The reported followers of this profile.
+ FollowersCount int `json:"followers_count"`
+ // The reported follows of this profile.
+ FollowingCount int `json:"following_count"`
+ // How many statuses are attached to this account.
+ StatusesCount int `json:"statuses_count"`
+ // When the most recent status was posted. (ISO 8601 Datetime)
+ LastStatusAt string `json:"last_status_at"`
+ // Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned.
+ Emojis []Emoji `json:"emojis"`
+ // Additional metadata attached to a profile as name-value pairs.
+ Fields []Field `json:"fields"`
+ // An extra entity returned when an account is suspended.
+ Suspended bool `json:"suspended,omitempty"`
+ // When a timed mute will expire, if applicable. (ISO 8601 Datetime)
+ MuteExpiresAt string `json:"mute_expires_at,omitempty"`
+ // An extra entity to be used with API methods to verify credentials and update credentials.
+ Source *Source `json:"source,omitempty"`
+}
+
+// AccountCreateRequest represents the form submitted during a POST request to /api/v1/accounts.
+// See https://docs.joinmastodon.org/methods/accounts/
+type AccountCreateRequest struct {
+ // Text that will be reviewed by moderators if registrations require manual approval.
+ Reason string `form:"reason"`
+ // The desired username for the account
+ Username string `form:"username" binding:"required"`
+ // The email address to be used for login
+ Email string `form:"email" binding:"required"`
+ // The password to be used for login
+ Password string `form:"password" binding:"required"`
+ // Whether the user agrees to the local rules, terms, and policies.
+ // These should be presented to the user in order to allow them to consent before setting this parameter to TRUE.
+ Agreement bool `form:"agreement" binding:"required"`
+ // The language of the confirmation email that will be sent
+ Locale string `form:"locale" binding:"required"`
+ // The IP of the sign up request, will not be parsed from the form but must be added manually
+ IP net.IP `form:"-"`
+}
+
+// UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials.
+// See https://docs.joinmastodon.org/methods/accounts/
+type UpdateCredentialsRequest struct {
+ // Whether the account should be shown in the profile directory.
+ Discoverable *bool `form:"discoverable"`
+ // Whether the account has a bot flag.
+ Bot *bool `form:"bot"`
+ // The display name to use for the profile.
+ DisplayName *string `form:"display_name"`
+ // The account bio.
+ Note *string `form:"note"`
+ // Avatar image encoded using multipart/form-data
+ Avatar *multipart.FileHeader `form:"avatar"`
+ // Header image encoded using multipart/form-data
+ Header *multipart.FileHeader `form:"header"`
+ // Whether manual approval of follow requests is required.
+ Locked *bool `form:"locked"`
+ // New Source values for this account
+ Source *UpdateSource `form:"source"`
+ // Profile metadata name and value
+ FieldsAttributes *[]UpdateField `form:"fields_attributes"`
+}
+
+// UpdateSource is to be used specifically in an UpdateCredentialsRequest.
+type UpdateSource struct {
+ // Default post privacy for authored statuses.
+ Privacy *string `form:"privacy"`
+ // Whether to mark authored statuses as sensitive by default.
+ Sensitive *bool `form:"sensitive"`
+ // Default language to use for authored statuses. (ISO 6391)
+ Language *string `form:"language"`
+}
+
+// UpdateField is to be used specifically in an UpdateCredentialsRequest.
+// By default, max 4 fields and 255 characters per property/value.
+type UpdateField struct {
+ // Name of the field
+ Name *string `form:"name"`
+ // Value of the field
+ Value *string `form:"value"`
+}
diff --git a/internal/api/model/activity.go b/internal/api/model/activity.go
@@ -0,0 +1,31 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Activity represents the mastodon-api Activity type. See here: https://docs.joinmastodon.org/entities/activity/
+type Activity struct {
+ // Midnight at the first day of the week. (UNIX Timestamp as string)
+ Week string `json:"week"`
+ // Statuses created since the week began. Integer cast to string.
+ Statuses string `json:"statuses"`
+ // User logins since the week began. Integer cast as string.
+ Logins string `json:"logins"`
+ // User registrations since the week began. Integer cast as string.
+ Registrations string `json:"registrations"`
+}
diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go
@@ -0,0 +1,81 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// AdminAccountInfo represents the *admin* view of an account's details. See here: https://docs.joinmastodon.org/entities/admin-account/
+type AdminAccountInfo struct {
+ // The ID of the account in the database.
+ ID string `json:"id"`
+ // The username of the account.
+ Username string `json:"username"`
+ // The domain of the account.
+ Domain string `json:"domain"`
+ // When the account was first discovered. (ISO 8601 Datetime)
+ CreatedAt string `json:"created_at"`
+ // The email address associated with the account.
+ Email string `json:"email"`
+ // The IP address last used to login to this account.
+ IP string `json:"ip"`
+ // The locale of the account. (ISO 639 Part 1 two-letter language code)
+ Locale string `json:"locale"`
+ // Invite request text
+ InviteRequest string `json:"invite_request"`
+ // The current role of the account.
+ Role string `json:"role"`
+ // Whether the account has confirmed their email address.
+ Confirmed bool `json:"confirmed"`
+ // Whether the account is currently approved.
+ Approved bool `json:"approved"`
+ // Whether the account is currently disabled.
+ Disabled bool `json:"disabled"`
+ // Whether the account is currently silenced
+ Silenced bool `json:"silenced"`
+ // Whether the account is currently suspended.
+ Suspended bool `json:"suspended"`
+ // User-level information about the account.
+ Account *Account `json:"account"`
+ // The ID of the application that created this account.
+ CreatedByApplicationID string `json:"created_by_application_id,omitempty"`
+ // The ID of the account that invited this user
+ InvitedByAccountID string `json:"invited_by_account_id"`
+}
+
+// AdminReportInfo represents the *admin* view of a report. See here: https://docs.joinmastodon.org/entities/admin-report/
+type AdminReportInfo struct {
+ // The ID of the report in the database.
+ ID string `json:"id"`
+ // The action taken to resolve this report.
+ ActionTaken string `json:"action_taken"`
+ // An optional reason for reporting.
+ Comment string `json:"comment"`
+ // The time the report was filed. (ISO 8601 Datetime)
+ CreatedAt string `json:"created_at"`
+ // The time of last action on this report. (ISO 8601 Datetime)
+ UpdatedAt string `json:"updated_at"`
+ // The account which filed the report.
+ Account *Account `json:"account"`
+ // The account being reported.
+ TargetAccount *Account `json:"target_account"`
+ // The account of the moderator assigned to this report.
+ AssignedAccount *Account `json:"assigned_account"`
+ // The action taken by the moderator who handled the report.
+ ActionTakenByAccount string `json:"action_taken_by_account"`
+ // Statuses attached to the report, for context.
+ Statuses []Status `json:"statuses"`
+}
diff --git a/internal/api/model/announcement.go b/internal/api/model/announcement.go
@@ -0,0 +1,37 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Announcement represents an admin/moderator announcement for local users. See here: https://docs.joinmastodon.org/entities/announcement/
+type Announcement struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ StartsAt string `json:"starts_at"`
+ EndsAt string `json:"ends_at"`
+ AllDay bool `json:"all_day"`
+ PublishedAt string `json:"published_at"`
+ UpdatedAt string `json:"updated_at"`
+ Published bool `json:"published"`
+ Read bool `json:"read"`
+ Mentions []Mention `json:"mentions"`
+ Statuses []Status `json:"statuses"`
+ Tags []Tag `json:"tags"`
+ Emojis []Emoji `json:"emoji"`
+ Reactions []AnnouncementReaction `json:"reactions"`
+}
diff --git a/internal/api/model/announcementreaction.go b/internal/api/model/announcementreaction.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 <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// AnnouncementReaction represents a user reaction to admin/moderator announcement. See here: https://docs.joinmastodon.org/entities/announcementreaction/
+type AnnouncementReaction struct {
+ // The emoji used for the reaction. Either a unicode emoji, or a custom emoji's shortcode.
+ Name string `json:"name"`
+ // The total number of users who have added this reaction.
+ Count int `json:"count"`
+ // Whether the authorized user has added this reaction to the announcement.
+ Me bool `json:"me"`
+ // A link to the custom emoji.
+ URL string `json:"url,omitempty"`
+ // A link to a non-animated version of the custom emoji.
+ StaticURL string `json:"static_url,omitempty"`
+}
diff --git a/internal/api/model/application.go b/internal/api/model/application.go
@@ -0,0 +1,55 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/.
+// Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user.
+// See https://docs.joinmastodon.org/methods/apps/
+type Application struct {
+ // The application ID in the db
+ ID string `json:"id,omitempty"`
+ // The name of your application.
+ Name string `json:"name"`
+ // The website associated with your application (url)
+ Website string `json:"website,omitempty"`
+ // Where the user should be redirected after authorization.
+ RedirectURI string `json:"redirect_uri,omitempty"`
+ // ClientID to use when obtaining an oauth token for this application (ie., in client_id parameter of https://docs.joinmastodon.org/methods/apps/)
+ ClientID string `json:"client_id,omitempty"`
+ // Client secret to use when obtaining an auth token for this application (ie., in client_secret parameter of https://docs.joinmastodon.org/methods/apps/)
+ ClientSecret string `json:"client_secret,omitempty"`
+ // Used for Push Streaming API. Returned with POST /api/v1/apps. Equivalent to https://docs.joinmastodon.org/entities/pushsubscription/#server_key
+ VapidKey string `json:"vapid_key,omitempty"`
+}
+
+// ApplicationCreateRequest represents a POST request to https://example.org/api/v1/apps.
+// See here: https://docs.joinmastodon.org/methods/apps/
+// And here: https://docs.joinmastodon.org/client/token/
+type ApplicationCreateRequest struct {
+ // A name for your application
+ ClientName string `form:"client_name" binding:"required"`
+ // Where the user should be redirected after authorization.
+ // To display the authorization code to the user instead of redirecting
+ // to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter.
+ RedirectURIs string `form:"redirect_uris" binding:"required"`
+ // Space separated list of scopes. If none is provided, defaults to read.
+ Scopes string `form:"scopes"`
+ // A URL to the homepage of your app
+ Website string `form:"website"`
+}
diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go
@@ -0,0 +1,98 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+import "mime/multipart"
+
+// AttachmentRequest represents the form data parameters submitted by a client during a media upload request.
+// See: https://docs.joinmastodon.org/methods/statuses/media/
+type AttachmentRequest struct {
+ File *multipart.FileHeader `form:"file"`
+ Thumbnail *multipart.FileHeader `form:"thumbnail"`
+ Description string `form:"description"`
+ Focus string `form:"focus"`
+}
+
+// Attachment represents the object returned to a client after a successful media upload request.
+// See: https://docs.joinmastodon.org/methods/statuses/media/
+type Attachment struct {
+ // The ID of the attachment in the database.
+ ID string `json:"id"`
+ // The type of the attachment.
+ // unknown = unsupported or unrecognized file type.
+ // image = Static image.
+ // gifv = Looping, soundless animation.
+ // video = Video clip.
+ // audio = Audio track.
+ Type string `json:"type"`
+ // The location of the original full-size attachment.
+ URL string `json:"url"`
+ // The location of a scaled-down preview of the attachment.
+ PreviewURL string `json:"preview_url"`
+ // The location of the full-size original attachment on the remote server.
+ RemoteURL string `json:"remote_url,omitempty"`
+ // The location of a scaled-down preview of the attachment on the remote server.
+ PreviewRemoteURL string `json:"preview_remote_url,omitempty"`
+ // A shorter URL for the attachment.
+ TextURL string `json:"text_url,omitempty"`
+ // Metadata returned by Paperclip.
+ // May contain subtrees small and original, as well as various other top-level properties.
+ // More importantly, there may be another top-level focus Hash object as of 2.3.0, with coordinates can be used for smart thumbnail cropping.
+ // See https://docs.joinmastodon.org/methods/statuses/media/#focal-points points for more.
+ Meta MediaMeta `json:"meta,omitempty"`
+ // Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.
+ Description string `json:"description,omitempty"`
+ // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.
+ // See https://github.com/woltapp/blurhash
+ Blurhash string `json:"blurhash,omitempty"`
+}
+
+// MediaMeta describes the returned media
+type MediaMeta struct {
+ Length string `json:"length,omitempty"`
+ Duration float32 `json:"duration,omitempty"`
+ FPS uint16 `json:"fps,omitempty"`
+ Size string `json:"size,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Aspect float32 `json:"aspect,omitempty"`
+ AudioEncode string `json:"audio_encode,omitempty"`
+ AudioBitrate string `json:"audio_bitrate,omitempty"`
+ AudioChannels string `json:"audio_channels,omitempty"`
+ Original MediaDimensions `json:"original"`
+ Small MediaDimensions `json:"small,omitempty"`
+ Focus MediaFocus `json:"focus,omitempty"`
+}
+
+// MediaFocus describes the focal point of a piece of media. It should be returned to the caller as part of MediaMeta.
+type MediaFocus struct {
+ X float32 `json:"x"` // should be between -1 and 1
+ Y float32 `json:"y"` // should be between -1 and 1
+}
+
+// MediaDimensions describes the physical properties of a piece of media. It should be returned to the caller as part of MediaMeta.
+type MediaDimensions struct {
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ FrameRate string `json:"frame_rate,omitempty"`
+ Duration float32 `json:"duration,omitempty"`
+ Bitrate int `json:"bitrate,omitempty"`
+ Size string `json:"size,omitempty"`
+ Aspect float32 `json:"aspect,omitempty"`
+}
diff --git a/internal/api/model/card.go b/internal/api/model/card.go
@@ -0,0 +1,61 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Card represents a rich preview card that is generated using OpenGraph tags from a URL. See here: https://docs.joinmastodon.org/entities/card/
+type Card struct {
+ // REQUIRED
+
+ // Location of linked resource.
+ URL string `json:"url"`
+ // Title of linked resource.
+ Title string `json:"title"`
+ // Description of preview.
+ Description string `json:"description"`
+ // The type of the preview card.
+ // String (Enumerable, oneOf)
+ // link = Link OEmbed
+ // photo = Photo OEmbed
+ // video = Video OEmbed
+ // rich = iframe OEmbed. Not currently accepted, so won't show up in practice.
+ Type string `json:"type"`
+
+ // OPTIONAL
+
+ // The author of the original resource.
+ AuthorName string `json:"author_name"`
+ // A link to the author of the original resource.
+ AuthorURL string `json:"author_url"`
+ // The provider of the original resource.
+ ProviderName string `json:"provider_name"`
+ // A link to the provider of the original resource.
+ ProviderURL string `json:"provider_url"`
+ // HTML to be used for generating the preview card.
+ HTML string `json:"html"`
+ // Width of preview, in pixels.
+ Width int `json:"width"`
+ // Height of preview, in pixels.
+ Height int `json:"height"`
+ // Preview thumbnail.
+ Image string `json:"image"`
+ // Used for photo embeds, instead of custom html.
+ EmbedURL string `json:"embed_url"`
+ // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.
+ Blurhash string `json:"blurhash"`
+}
diff --git a/internal/api/model/content.go b/internal/api/model/content.go
@@ -0,0 +1,41 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Content wraps everything needed to serve a blob of content (some kind of media) through the API.
+type Content struct {
+ // MIME content type
+ ContentType string
+ // ContentLength in bytes
+ ContentLength int64
+ // Actual content blob
+ Content []byte
+}
+
+// GetContentRequestForm describes a piece of content desired by the caller of the fileserver API.
+type GetContentRequestForm struct {
+ // AccountID of the content owner
+ AccountID string
+ // MediaType of the content (should be convertible to a media.MediaType)
+ MediaType string
+ // MediaSize of the content (should be convertible to a media.MediaSize)
+ MediaSize string
+ // Filename of the content
+ FileName string
+}
diff --git a/internal/api/model/context.go b/internal/api/model/context.go
@@ -0,0 +1,27 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Context represents the tree around a given status. Used for reconstructing threads of statuses. See: https://docs.joinmastodon.org/entities/context/
+type Context struct {
+ // Parents in the thread.
+ Ancestors []Status `json:"ancestors"`
+ // Children in the thread.
+ Descendants []Status `json:"descendants"`
+}
diff --git a/internal/api/model/conversation.go b/internal/api/model/conversation.go
@@ -0,0 +1,36 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Conversation represents a conversation with "direct message" visibility. See https://docs.joinmastodon.org/entities/conversation/
+type Conversation struct {
+ // REQUIRED
+
+ // Local database ID of the conversation.
+ ID string `json:"id"`
+ // Participants in the conversation.
+ Accounts []Account `json:"accounts"`
+ // Is the conversation currently marked as unread?
+ Unread bool `json:"unread"`
+
+ // OPTIONAL
+
+ // The last status in the conversation, to be used for optional display.
+ LastStatus *Status `json:"last_status"`
+}
diff --git a/internal/api/model/emoji.go b/internal/api/model/emoji.go
@@ -0,0 +1,48 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+import "mime/multipart"
+
+// Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/
+type Emoji struct {
+ // REQUIRED
+
+ // The name of the custom emoji.
+ Shortcode string `json:"shortcode"`
+ // A link to the custom emoji.
+ URL string `json:"url"`
+ // A link to a static copy of the custom emoji.
+ StaticURL string `json:"static_url"`
+ // Whether this Emoji should be visible in the picker or unlisted.
+ VisibleInPicker bool `json:"visible_in_picker"`
+
+ // OPTIONAL
+
+ // Used for sorting custom emoji in the picker.
+ Category string `json:"category,omitempty"`
+}
+
+// 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"`
+ // Image file to use for the emoji. Must be png or gif and no larger than 50kb.
+ Image *multipart.FileHeader `form:"image" validation:"required"`
+}
diff --git a/internal/api/model/error.go b/internal/api/model/error.go
@@ -0,0 +1,32 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Error represents an error message returned from the API. See https://docs.joinmastodon.org/entities/error/
+type Error struct {
+ // REQUIRED
+
+ // The error message.
+ Error string `json:"error"`
+
+ // OPTIONAL
+
+ // A longer description of the error, mainly provided with the OAuth API.
+ ErrorDescription string `json:"error_description"`
+}
diff --git a/internal/api/model/featuredtag.go b/internal/api/model/featuredtag.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 <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// FeaturedTag represents a hashtag that is featured on a profile. See https://docs.joinmastodon.org/entities/featuredtag/
+type FeaturedTag struct {
+ // The internal ID of the featured tag in the database.
+ ID string `json:"id"`
+ // The name of the hashtag being featured.
+ Name string `json:"name"`
+ // A link to all statuses by a user that contain this hashtag.
+ URL string `json:"url"`
+ // The number of authored statuses containing this hashtag.
+ StatusesCount int `json:"statuses_count"`
+ // The timestamp of the last authored status containing this hashtag. (ISO 8601 Datetime)
+ LastStatusAt string `json:"last_status_at"`
+}
diff --git a/internal/api/model/field.go b/internal/api/model/field.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 <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Field represents a profile field as a name-value pair with optional verification. See https://docs.joinmastodon.org/entities/field/
+type Field struct {
+ // REQUIRED
+
+ // The key of a given field's key-value pair.
+ Name string `json:"name"`
+ // The value associated with the name key.
+ Value string `json:"value"`
+
+ // OPTIONAL
+ // Timestamp of when the server verified a URL value for a rel="me” link. String (ISO 8601 Datetime) if value is a verified URL
+ VerifiedAt string `json:"verified_at,omitempty"`
+}
diff --git a/internal/api/model/filter.go b/internal/api/model/filter.go
@@ -0,0 +1,46 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Filter represents a user-defined filter for determining which statuses should not be shown to the user. See https://docs.joinmastodon.org/entities/filter/
+// If whole_word is true , client app should do:
+// Define ‘word constituent character’ for your app. In the official implementation, it’s [A-Za-z0-9_] in JavaScript, and [[:word:]] in Ruby.
+// Ruby uses the POSIX character class (Letter | Mark | Decimal_Number | Connector_Punctuation).
+// If the phrase starts with a word character, and if the previous character before matched range is a word character, its matched range should be treated to not match.
+// If the phrase ends with a word character, and if the next character after matched range is a word character, its matched range should be treated to not match.
+// Please check app/javascript/mastodon/selectors/index.js and app/lib/feed_manager.rb in the Mastodon source code for more details.
+type Filter struct {
+ // The ID of the filter in the database.
+ ID string `json:"id"`
+ // The text to be filtered.
+ Phrase string `json:"text"`
+ // The contexts in which the filter should be applied.
+ // Array of String (Enumerable anyOf)
+ // home = home timeline and lists
+ // notifications = notifications timeline
+ // public = public timelines
+ // thread = expanded thread of a detailed status
+ Context []string `json:"context"`
+ // Should the filter consider word boundaries?
+ WholeWord bool `json:"whole_word"`
+ // When the filter should no longer be applied (ISO 8601 Datetime), or null if the filter does not expire
+ ExpiresAt string `json:"expires_at,omitempty"`
+ // Should matching entities in home and notifications be dropped by the server?
+ Irreversible bool `json:"irreversible"`
+}
diff --git a/internal/api/model/history.go b/internal/api/model/history.go
@@ -0,0 +1,29 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// History represents daily usage history of a hashtag. See https://docs.joinmastodon.org/entities/history/
+type History struct {
+ // UNIX timestamp on midnight of the given day (string cast from integer).
+ Day string `json:"day"`
+ // The counted usage of the tag within that day (string cast from integer).
+ Uses string `json:"uses"`
+ // The total of accounts using the tag within that day (string cast from integer).
+ Accounts string `json:"accounts"`
+}
diff --git a/internal/api/model/identityproof.go b/internal/api/model/identityproof.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 <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// IdentityProof represents a proof from an external identity provider. See https://docs.joinmastodon.org/entities/identityproof/
+type IdentityProof struct {
+ // The name of the identity provider.
+ Provider string `json:"provider"`
+ // The account owner's username on the identity provider's service.
+ ProviderUsername string `json:"provider_username"`
+ // The account owner's profile URL on the identity provider.
+ ProfileURL string `json:"profile_url"`
+ // A link to a statement of identity proof, hosted by the identity provider.
+ ProofURL string `json:"proof_url"`
+ // When the identity proof was last updated.
+ UpdatedAt string `json:"updated_at"`
+}
diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go
@@ -0,0 +1,72 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/
+type Instance struct {
+ // REQUIRED
+
+ // The domain name of the instance.
+ URI string `json:"uri"`
+ // The title of the website.
+ Title string `json:"title"`
+ // Admin-defined description of the Mastodon site.
+ Description string `json:"description"`
+ // A shorter description defined by the admin.
+ ShortDescription string `json:"short_description"`
+ // An email that may be contacted for any inquiries.
+ Email string `json:"email"`
+ // The version of Mastodon installed on the instance.
+ Version string `json:"version"`
+ // Primary langauges of the website and its staff.
+ Languages []string `json:"languages"`
+ // Whether registrations are enabled.
+ Registrations bool `json:"registrations"`
+ // Whether registrations require moderator approval.
+ ApprovalRequired bool `json:"approval_required"`
+ // Whether invites are enabled.
+ InvitesEnabled bool `json:"invites_enabled"`
+ // URLs of interest for clients apps.
+ URLS *InstanceURLs `json:"urls"`
+ // Statistics about how much information the instance contains.
+ Stats *InstanceStats `json:"stats"`
+
+ // OPTIONAL
+
+ // Banner image for the website.
+ Thumbnail string `json:"thumbnail,omitempty"`
+ // A user that can be contacted, as an alternative to email.
+ ContactAccount *Account `json:"contact_account,omitempty"`
+}
+
+// InstanceURLs represents URLs necessary for successfully connecting to the instance as a user. See https://docs.joinmastodon.org/entities/instance/
+type InstanceURLs struct {
+ // Websockets address for push streaming.
+ StreamingAPI string `json:"streaming_api"`
+}
+
+// InstanceStats represents some public-facing stats about the instance. See https://docs.joinmastodon.org/entities/instance/
+type InstanceStats struct {
+ // Users registered on this instance.
+ UserCount int `json:"user_count"`
+ // Statuses authored by users on instance.
+ StatusCount int `json:"status_count"`
+ // Domains federated with this instance.
+ DomainCount int `json:"domain_count"`
+}
diff --git a/internal/api/model/list.go b/internal/api/model/list.go
@@ -0,0 +1,31 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// List represents a list of some users that the authenticated user follows. See https://docs.joinmastodon.org/entities/list/
+type List struct {
+ // The internal database ID of the list.
+ ID string `json:"id"`
+ // The user-defined title of the list.
+ Title string `json:"title"`
+ // followed = Show replies to any followed user
+ // list = Show replies to members of the list
+ // none = Show replies to no one
+ RepliesPolicy string `json:"replies_policy"`
+}
diff --git a/internal/api/model/marker.go b/internal/api/model/marker.go
@@ -0,0 +1,37 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Marker represents the last read position within a user's timelines. See https://docs.joinmastodon.org/entities/marker/
+type Marker struct {
+ // Information about the user's position in the home timeline.
+ Home *TimelineMarker `json:"home"`
+ // Information about the user's position in their notifications.
+ Notifications *TimelineMarker `json:"notifications"`
+}
+
+// TimelineMarker contains information about a user's progress through a specific timeline. See https://docs.joinmastodon.org/entities/marker/
+type TimelineMarker struct {
+ // The ID of the most recently viewed entity.
+ LastReadID string `json:"last_read_id"`
+ // The timestamp of when the marker was set (ISO 8601 Datetime)
+ UpdatedAt string `json:"updated_at"`
+ // Used for locking to prevent write conflicts.
+ Version string `json:"version"`
+}
diff --git a/internal/api/model/mention.go b/internal/api/model/mention.go
@@ -0,0 +1,31 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Mention represents the mastodon-api mention type, as documented here: https://docs.joinmastodon.org/entities/mention/
+type Mention struct {
+ // The account id of the mentioned user.
+ ID string `json:"id"`
+ // The username of the mentioned user.
+ Username string `json:"username"`
+ // The location of the mentioned user's profile.
+ URL string `json:"url"`
+ // The webfinger acct: URI of the mentioned user. Equivalent to username for local users, or username@domain for remote users.
+ Acct string `json:"acct"`
+}
diff --git a/internal/api/model/notification.go b/internal/api/model/notification.go
@@ -0,0 +1,45 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Notification represents a notification of an event relevant to the user. See https://docs.joinmastodon.org/entities/notification/
+type Notification struct {
+ // REQUIRED
+
+ // The id of the notification in the database.
+ ID string `json:"id"`
+ // The type of event that resulted in the notification.
+ // follow = Someone followed you
+ // follow_request = Someone requested to follow you
+ // mention = Someone mentioned you in their status
+ // reblog = Someone boosted one of your statuses
+ // favourite = Someone favourited one of your statuses
+ // poll = A poll you have voted in or created has ended
+ // status = Someone you enabled notifications for has posted a status
+ Type string `json:"type"`
+ // The timestamp of the notification (ISO 8601 Datetime)
+ CreatedAt string `json:"created_at"`
+ // The account that performed the action that generated the notification.
+ Account *Account `json:"account"`
+
+ // OPTIONAL
+
+ // Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.
+ Status *Status `json:"status"`
+}
diff --git a/internal/api/model/oauth.go b/internal/api/model/oauth.go
@@ -0,0 +1,37 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// OAuthAuthorize represents a request sent to https://example.org/oauth/authorize
+// See here: https://docs.joinmastodon.org/methods/apps/oauth/
+type OAuthAuthorize struct {
+ // Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance.
+ ForceLogin string `form:"force_login,omitempty"`
+ // Should be set equal to `code`.
+ ResponseType string `form:"response_type"`
+ // Client ID, obtained during app registration.
+ ClientID string `form:"client_id"`
+ // Set a URI to redirect the user to.
+ // If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead.
+ // Must match one of the redirect URIs declared during app registration.
+ RedirectURI string `form:"redirect_uri"`
+ // List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters).
+ // Must be a subset of scopes declared during app registration. If not provided, defaults to read.
+ Scope string `form:"scope,omitempty"`
+}
diff --git a/internal/api/model/poll.go b/internal/api/model/poll.go
@@ -0,0 +1,64 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Poll represents the mastodon-api poll type, as described here: https://docs.joinmastodon.org/entities/poll/
+type Poll struct {
+ // The ID of the poll in the database.
+ ID string `json:"id"`
+ // When the poll ends. (ISO 8601 Datetime), or null if the poll does not end
+ ExpiresAt string `json:"expires_at"`
+ // Is the poll currently expired?
+ Expired bool `json:"expired"`
+ // Does the poll allow multiple-choice answers?
+ Multiple bool `json:"multiple"`
+ // How many votes have been received.
+ VotesCount int `json:"votes_count"`
+ // How many unique accounts have voted on a multiple-choice poll. Null if multiple is false.
+ VotersCount int `json:"voters_count,omitempty"`
+ // When called with a user token, has the authorized user voted?
+ Voted bool `json:"voted,omitempty"`
+ // When called with a user token, which options has the authorized user chosen? Contains an array of index values for options.
+ OwnVotes []int `json:"own_votes,omitempty"`
+ // Possible answers for the poll.
+ Options []PollOptions `json:"options"`
+ // Custom emoji to be used for rendering poll options.
+ Emojis []Emoji `json:"emojis"`
+}
+
+// PollOptions represents the current vote counts for different poll options
+type PollOptions struct {
+ // The text value of the poll option. String.
+ Title string `json:"title"`
+ // The number of received votes for this option. Number, or null if results are not published yet.
+ VotesCount int `json:"votes_count,omitempty"`
+}
+
+// PollRequest represents a mastodon-api poll attached to a status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/
+// It should be used at the path https://example.org/api/v1/statuses
+type PollRequest struct {
+ // Array of possible answers. If provided, media_ids cannot be used, and poll[expires_in] must be provided.
+ Options []string `form:"options"`
+ // Duration the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.
+ ExpiresIn int `form:"expires_in"`
+ // Allow multiple choices?
+ Multiple bool `form:"multiple"`
+ // Hide vote counts until the poll ends?
+ HideTotals bool `form:"hide_totals"`
+}
diff --git a/internal/api/model/preferences.go b/internal/api/model/preferences.go
@@ -0,0 +1,40 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Preferences represents a user's preferences. See https://docs.joinmastodon.org/entities/preferences/
+type Preferences struct {
+ // Default visibility for new posts.
+ // public = Public post
+ // unlisted = Unlisted post
+ // private = Followers-only post
+ // direct = Direct post
+ PostingDefaultVisibility string `json:"posting:default:visibility"`
+ // Default sensitivity flag for new posts.
+ PostingDefaultSensitive bool `json:"posting:default:sensitive"`
+ // Default language for new posts. (ISO 639-1 language two-letter code), or null
+ PostingDefaultLanguage string `json:"posting:default:language,omitempty"`
+ // Whether media attachments should be automatically displayed or blurred/hidden.
+ // default = Hide media marked as sensitive
+ // show_all = Always show all media by default, regardless of sensitivity
+ // hide_all = Always hide all media by default, regardless of sensitivity
+ ReadingExpandMedia string `json:"reading:expand:media"`
+ // Whether CWs should be expanded by default.
+ ReadingExpandSpoilers bool `json:"reading:expand:spoilers"`
+}
diff --git a/internal/api/model/pushsubscription.go b/internal/api/model/pushsubscription.go
@@ -0,0 +1,45 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// PushSubscription represents a subscription to the push streaming server. See https://docs.joinmastodon.org/entities/pushsubscription/
+type PushSubscription struct {
+ // The id of the push subscription in the database.
+ ID string `json:"id"`
+ // Where push alerts will be sent to.
+ Endpoint string `json:"endpoint"`
+ // The streaming server's VAPID key.
+ ServerKey string `json:"server_key"`
+ // Which alerts should be delivered to the endpoint.
+ Alerts *PushSubscriptionAlerts `json:"alerts"`
+}
+
+// PushSubscriptionAlerts represents the specific alerts that this push subscription will give.
+type PushSubscriptionAlerts struct {
+ // Receive a push notification when someone has followed you?
+ Follow bool `json:"follow"`
+ // Receive a push notification when a status you created has been favourited by someone else?
+ Favourite bool `json:"favourite"`
+ // Receive a push notification when someone else has mentioned you in a status?
+ Mention bool `json:"mention"`
+ // Receive a push notification when a status you created has been boosted by someone else?
+ Reblog bool `json:"reblog"`
+ // Receive a push notification when a poll you voted in or created has ended?
+ Poll bool `json:"poll"`
+}
diff --git a/internal/api/model/relationship.go b/internal/api/model/relationship.go
@@ -0,0 +1,49 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Relationship represents a relationship between accounts. See https://docs.joinmastodon.org/entities/relationship/
+type Relationship struct {
+ // The account id.
+ ID string `json:"id"`
+ // Are you following this user?
+ Following bool `json:"following"`
+ // Are you receiving this user's boosts in your home timeline?
+ ShowingReblogs bool `json:"showing_reblogs"`
+ // Have you enabled notifications for this user?
+ Notifying bool `json:"notifying"`
+ // Are you followed by this user?
+ FollowedBy bool `json:"followed_by"`
+ // Are you blocking this user?
+ Blocking bool `json:"blocking"`
+ // Is this user blocking you?
+ BlockedBy bool `json:"blocked_by"`
+ // Are you muting this user?
+ Muting bool `json:"muting"`
+ // Are you muting notifications from this user?
+ MutingNotifications bool `json:"muting_notifications"`
+ // Do you have a pending follow request for this user?
+ Requested bool `json:"requested"`
+ // Are you blocking this user's domain?
+ DomainBlocking bool `json:"domain_blocking"`
+ // Are you featuring this user on your profile?
+ Endorsed bool `json:"endorsed"`
+ // Your note on this account.
+ Note string `json:"note"`
+}
diff --git a/internal/api/model/results.go b/internal/api/model/results.go
@@ -0,0 +1,29 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Results represents the results of a search. See https://docs.joinmastodon.org/entities/results/
+type Results struct {
+ // Accounts which match the given query
+ Accounts []Account `json:"accounts"`
+ // Statuses which match the given query
+ Statuses []Status `json:"statuses"`
+ // Hashtags which match the given query
+ Hashtags []Tag `json:"hashtags"`
+}
diff --git a/internal/api/model/scheduledstatus.go b/internal/api/model/scheduledstatus.go
@@ -0,0 +1,39 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// ScheduledStatus represents a status that will be published at a future scheduled date. See https://docs.joinmastodon.org/entities/scheduledstatus/
+type ScheduledStatus struct {
+ ID string `json:"id"`
+ ScheduledAt string `json:"scheduled_at"`
+ Params *StatusParams `json:"params"`
+ MediaAttachments []Attachment `json:"media_attachments"`
+}
+
+// StatusParams represents parameters for a scheduled status. See https://docs.joinmastodon.org/entities/scheduledstatus/
+type StatusParams struct {
+ Text string `json:"text"`
+ InReplyToID string `json:"in_reply_to_id,omitempty"`
+ MediaIDs []string `json:"media_ids,omitempty"`
+ Sensitive bool `json:"sensitive,omitempty"`
+ SpoilerText string `json:"spoiler_text,omitempty"`
+ Visibility string `json:"visibility"`
+ ScheduledAt string `json:"scheduled_at,omitempty"`
+ ApplicationID string `json:"application_id"`
+}
diff --git a/internal/api/model/source.go b/internal/api/model/source.go
@@ -0,0 +1,41 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Source represents display or publishing preferences of user's own account.
+// Returned as an additional entity when verifying and updated credentials, as an attribute of Account.
+// See https://docs.joinmastodon.org/entities/source/
+type Source struct {
+ // The default post privacy to be used for new statuses.
+ // public = Public post
+ // unlisted = Unlisted post
+ // private = Followers-only post
+ // direct = Direct post
+ Privacy Visibility `json:"privacy,omitempty"`
+ // Whether new statuses should be marked sensitive by default.
+ Sensitive bool `json:"sensitive,omitempty"`
+ // The default posting language for new statuses.
+ Language string `json:"language,omitempty"`
+ // Profile bio.
+ Note string `json:"note"`
+ // Metadata about the account.
+ Fields []Field `json:"fields"`
+ // The number of pending follow requests.
+ FollowRequestsCount int `json:"follow_requests_count,omitempty"`
+}
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
@@ -0,0 +1,138 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/
+type Status struct {
+ // ID of the status in the database.
+ ID string `json:"id"`
+ // The date when this status was created (ISO 8601 Datetime)
+ CreatedAt string `json:"created_at"`
+ // ID of the status being replied.
+ InReplyToID string `json:"in_reply_to_id,omitempty"`
+ // ID of the account being replied to.
+ InReplyToAccountID string `json:"in_reply_to_account_id,omitempty"`
+ // Is this status marked as sensitive content?
+ Sensitive bool `json:"sensitive"`
+ // Subject or summary line, below which status content is collapsed until expanded.
+ SpoilerText string `json:"spoiler_text,omitempty"`
+ // Visibility of this status.
+ Visibility Visibility `json:"visibility"`
+ // Primary language of this status. (ISO 639 Part 1 two-letter language code)
+ Language string `json:"language"`
+ // URI of the status used for federation.
+ URI string `json:"uri"`
+ // A link to the status's HTML representation.
+ URL string `json:"url"`
+ // How many replies this status has received.
+ RepliesCount int `json:"replies_count"`
+ // How many boosts this status has received.
+ ReblogsCount int `json:"reblogs_count"`
+ // How many favourites this status has received.
+ FavouritesCount int `json:"favourites_count"`
+ // Have you favourited this status?
+ Favourited bool `json:"favourited"`
+ // Have you boosted this status?
+ Reblogged bool `json:"reblogged"`
+ // Have you muted notifications for this status's conversation?
+ Muted bool `json:"muted"`
+ // Have you bookmarked this status?
+ Bookmarked bool `json:"bookmarked"`
+ // Have you pinned this status? Only appears if the status is pinnable.
+ Pinned bool `json:"pinned"`
+ // HTML-encoded status content.
+ Content string `json:"content"`
+ // The status being reblogged.
+ Reblog *Status `json:"reblog,omitempty"`
+ // The application used to post this status.
+ Application *Application `json:"application"`
+ // The account that authored this status.
+ Account *Account `json:"account"`
+ // Media that is attached to this status.
+ MediaAttachments []Attachment `json:"media_attachments"`
+ // Mentions of users within the status content.
+ Mentions []Mention `json:"mentions"`
+ // Hashtags used within the status content.
+ Tags []Tag `json:"tags"`
+ // Custom emoji to be used when rendering status content.
+ Emojis []Emoji `json:"emojis"`
+ // Preview card for links included within status content.
+ Card *Card `json:"card"`
+ // The poll attached to the status.
+ Poll *Poll `json:"poll"`
+ // Plain-text source of a status. Returned instead of content when status is deleted,
+ // so the user may redraft from the source text without the client having to reverse-engineer
+ // the original text from the HTML content.
+ Text string `json:"text"`
+}
+
+// StatusCreateRequest represents a mastodon-api status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/
+// It should be used at the path https://mastodon.example/api/v1/statuses
+type StatusCreateRequest struct {
+ // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
+ Status string `form:"status"`
+ // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
+ MediaIDs []string `form:"media_ids"`
+ // Poll to include with this status.
+ Poll *PollRequest `form:"poll"`
+ // ID of the status being replied to, if status is a reply
+ InReplyToID string `form:"in_reply_to_id"`
+ // Mark status and attached media as sensitive?
+ Sensitive bool `form:"sensitive"`
+ // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
+ SpoilerText string `form:"spoiler_text"`
+ // Visibility of the posted status. Enumerable oneOf public, unlisted, private, direct.
+ Visibility Visibility `form:"visibility"`
+ // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future.
+ ScheduledAt string `form:"scheduled_at"`
+ // ISO 639 language code for this status.
+ Language string `form:"language"`
+}
+
+// Visibility denotes the visibility of this status to other users
+type Visibility string
+
+const (
+ // VisibilityPublic means visible to everyone
+ VisibilityPublic Visibility = "public"
+ // VisibilityUnlisted means visible to everyone but only on home timelines or in lists
+ VisibilityUnlisted Visibility = "unlisted"
+ // VisibilityPrivate means visible to followers only
+ VisibilityPrivate Visibility = "private"
+ // VisibilityDirect means visible only to tagged recipients
+ VisibilityDirect Visibility = "direct"
+)
+
+type AdvancedStatusCreateForm struct {
+ StatusCreateRequest
+ AdvancedVisibilityFlagsForm
+}
+
+type AdvancedVisibilityFlagsForm struct {
+ // The gotosocial visibility model
+ VisibilityAdvanced *string `form:"visibility_advanced"`
+ // This status will be federated beyond the local timeline(s)
+ Federated *bool `form:"federated"`
+ // This status can be boosted/reblogged
+ Boostable *bool `form:"boostable"`
+ // This status can be replied to
+ Replyable *bool `form:"replyable"`
+ // This status can be liked/faved
+ Likeable *bool `form:"likeable"`
+}
diff --git a/internal/api/model/tag.go b/internal/api/model/tag.go
@@ -0,0 +1,27 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// 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"`
+}
diff --git a/internal/api/model/token.go b/internal/api/model/token.go
@@ -0,0 +1,31 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package model
+
+// Token represents an OAuth token used for authenticating with the API and performing actions.. See https://docs.joinmastodon.org/entities/token/
+type Token struct {
+ // An OAuth token to be used for authorization.
+ AccessToken string `json:"access_token"`
+ // The OAuth token type. Mastodon uses Bearer tokens.
+ TokenType string `json:"token_type"`
+ // The OAuth scopes granted by this token, space-separated.
+ Scope string `json:"scope"`
+ // When the token was generated. (UNIX timestamp seconds)
+ CreatedAt int64 `json:"created_at"`
+}
diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go
@@ -0,0 +1,70 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package user
+
+import (
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+const (
+ // UsernameKey is for account usernames.
+ UsernameKey = "username"
+ // UsersBasePath is the base path for serving information about Users eg https://example.org/users
+ UsersBasePath = "/" + util.UsersPath
+ // UsersBasePathWithUsername is just the users base path with the Username key in it.
+ // Use this anywhere you need to know the username of the user being queried.
+ // Eg https://example.org/users/:username
+ UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey
+)
+
+// ActivityPubAcceptHeaders represents the Accept headers mentioned here:
+// https://www.w3.org/TR/activitypub/#retrieving-objects
+var ActivityPubAcceptHeaders = []string{
+ `application/activity+json`,
+ `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`,
+}
+
+// Module implements the FederationAPIModule interface
+type Module struct {
+ config *config.Config
+ processor message.Processor
+ log *logrus.Logger
+}
+
+// New returns a new auth module
+func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.FederationModule {
+ return &Module{
+ config: config,
+ processor: processor,
+ log: log,
+ }
+}
+
+// Route satisfies the RESTAPIModule interface
+func (m *Module) Route(s router.Router) error {
+ s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler)
+ return nil
+}
diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go
@@ -0,0 +1,40 @@
+package user_test
+
+import (
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type UserStandardTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ tc typeutils.TypeConverter
+ federator federation.Federator
+ processor message.Processor
+ storage storage.Storage
+
+ // standard suite models
+ testTokens map[string]*oauth.Token
+ testClients map[string]*oauth.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testAttachments map[string]*gtsmodel.MediaAttachment
+ testStatuses map[string]*gtsmodel.Status
+
+ // module being tested
+ userModule *user.Module
+}
diff --git a/internal/api/s2s/user/userget.go b/internal/api/s2s/user/userget.go
@@ -0,0 +1,67 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package user
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+)
+
+// UsersGETHandler should be served at https://example.org/users/:username.
+//
+// The goal here is to return the activitypub representation of an account
+// in the form of a vocab.ActivityStreamsPerson. This should only be served
+// to REMOTE SERVERS that present a valid signature on the GET request, on
+// behalf of a user, otherwise we risk leaking information about users publicly.
+//
+// And of course, the request should be refused if the account or server making the
+// request is blocked.
+func (m *Module) UsersGETHandler(c *gin.Context) {
+ l := m.log.WithFields(logrus.Fields{
+ "func": "UsersGETHandler",
+ "url": c.Request.RequestURI,
+ })
+
+ requestedUsername := c.Param(UsernameKey)
+ if requestedUsername == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"})
+ return
+ }
+
+ // make sure this actually an AP request
+ format := c.NegotiateFormat(ActivityPubAcceptHeaders...)
+ if format == "" {
+ c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"})
+ return
+ }
+ l.Tracef("negotiated format: %s", format)
+
+ // make a copy of the context to pass along so we don't break anything
+ cp := c.Copy()
+ user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetAPUser handles auth as well
+ if err != nil {
+ l.Info(err.Error())
+ c.JSON(err.Code(), gin.H{"error": err.Safe()})
+ return
+ }
+
+ c.JSON(http.StatusOK, user)
+}
diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go
@@ -0,0 +1,155 @@
+package user_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/go-fed/activity/streams"
+ "github.com/go-fed/activity/streams/vocab"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type UserGetTestSuite struct {
+ UserStandardTestSuite
+}
+
+func (suite *UserGetTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testAttachments = testrig.NewTestAttachments()
+ suite.testStatuses = testrig.NewTestStatuses()
+}
+
+func (suite *UserGetTestSuite) SetupTest() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.tc = testrig.NewTestTypeConverter(suite.db)
+ suite.storage = testrig.NewTestStorage()
+ suite.log = testrig.NewTestLog()
+ suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)))
+ suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator)
+ suite.userModule = user.New(suite.config, suite.processor, suite.log).(*user.Module)
+ testrig.StandardDBSetup(suite.db)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *UserGetTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+}
+
+func (suite *UserGetTestSuite) TestGetUser() {
+ // the dereference we're gonna use
+ signedRequest := testrig.NewTestDereferenceRequests(suite.testAccounts)["foss_satan_dereference_zork"]
+
+ requestingAccount := suite.testAccounts["remote_account_1"]
+ targetAccount := suite.testAccounts["local_account_1"]
+
+ encodedPublicKey, err := x509.MarshalPKIXPublicKey(requestingAccount.PublicKey)
+ assert.NoError(suite.T(), err)
+ publicKeyBytes := pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: encodedPublicKey,
+ })
+ publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n")
+
+ // for this test we need the client to return the public key of the requester on the 'remote' instance
+ responseBodyString := fmt.Sprintf(`
+ {
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1"
+ ],
+
+ "id": "%s",
+ "type": "Person",
+ "preferredUsername": "%s",
+ "inbox": "%s",
+
+ "publicKey": {
+ "id": "%s",
+ "owner": "%s",
+ "publicKeyPem": "%s"
+ }
+ }`, requestingAccount.URI, requestingAccount.Username, requestingAccount.InboxURI, requestingAccount.PublicKeyURI, requestingAccount.URI, publicKeyString)
+
+ // create a transport controller whose client will just return the response body string we specified above
+ tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
+ r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString)))
+ return &http.Response{
+ StatusCode: 200,
+ Body: r,
+ }, nil
+ }))
+ // get this transport controller embedded right in the user module we're testing
+ federator := testrig.NewTestFederator(suite.db, tc)
+ processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
+ userModule := user.New(suite.config, processor, suite.log).(*user.Module)
+
+ // setup request
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(user.UsersBasePathWithUsername, ":username", targetAccount.Username, 1)), nil) // the endpoint we're hitting
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: user.UsernameKey,
+ Value: targetAccount.Username,
+ },
+ }
+
+ // we need these headers for the request to be validated
+ ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
+ ctx.Request.Header.Set("Date", signedRequest.DateHeader)
+ ctx.Request.Header.Set("Digest", signedRequest.DigestHeader)
+
+ // trigger the function being tested
+ userModule.UsersGETHandler(ctx)
+
+ // check response
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+ b, err := ioutil.ReadAll(result.Body)
+ assert.NoError(suite.T(), err)
+
+ // should be a Person
+ m := make(map[string]interface{})
+ err = json.Unmarshal(b, &m)
+ assert.NoError(suite.T(), err)
+
+ t, err := streams.ToType(context.Background(), m)
+ assert.NoError(suite.T(), err)
+
+ person, ok := t.(vocab.ActivityStreamsPerson)
+ assert.True(suite.T(), ok)
+
+ // convert person to account
+ // since this account is already known, we should get a pretty full model of it from the conversion
+ a, err := suite.tc.ASRepresentationToAccount(person)
+ assert.NoError(suite.T(), err)
+ assert.EqualValues(suite.T(), targetAccount.Username, a.Username)
+}
+
+func TestUserGetTestSuite(t *testing.T) {
+ suite.Run(t, new(UserGetTestSuite))
+}
diff --git a/internal/apimodule/security/flocblock.go b/internal/api/security/flocblock.go
diff --git a/internal/api/security/security.go b/internal/api/security/security.go
@@ -0,0 +1,46 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package security
+
+import (
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/router"
+)
+
+// Module implements the ClientAPIModule interface for security middleware
+type Module struct {
+ config *config.Config
+ log *logrus.Logger
+}
+
+// New returns a new security module
+func New(config *config.Config, log *logrus.Logger) api.ClientModule {
+ return &Module{
+ config: config,
+ log: log,
+ }
+}
+
+// Route attaches security middleware to the given router
+func (m *Module) Route(s router.Router) error {
+ s.AttachMiddleware(m.FlocBlock)
+ return nil
+}
diff --git a/internal/apimodule/account/account.go b/internal/apimodule/account/account.go
@@ -1,117 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package account
-
-import (
- "fmt"
- "net/http"
- "strings"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-const (
- // IDKey is the key to use for retrieving account ID in requests
- IDKey = "id"
- // BasePath is the base API path for this module
- BasePath = "/api/v1/accounts"
- // BasePathWithID is the base path for this module with the ID key
- BasePathWithID = BasePath + "/:" + IDKey
- // VerifyPath is for verifying account credentials
- VerifyPath = BasePath + "/verify_credentials"
- // UpdateCredentialsPath is for updating account credentials
- UpdateCredentialsPath = BasePath + "/update_credentials"
-)
-
-// Module implements the ClientAPIModule interface for account-related actions
-type Module struct {
- config *config.Config
- db db.DB
- oauthServer oauth.Server
- mediaHandler media.Handler
- mastoConverter mastotypes.Converter
- log *logrus.Logger
-}
-
-// New returns a new account module
-func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
- return &Module{
- config: config,
- db: db,
- oauthServer: oauthServer,
- mediaHandler: mediaHandler,
- mastoConverter: mastoConverter,
- log: log,
- }
-}
-
-// Route attaches all routes from this module to the given router
-func (m *Module) Route(r router.Router) error {
- r.AttachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler)
- r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
- r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler)
- return nil
-}
-
-// CreateTables creates the required tables for this module in the given database
-func (m *Module) CreateTables(db db.DB) error {
- models := []interface{}{
- >smodel.User{},
- >smodel.Account{},
- >smodel.Follow{},
- >smodel.FollowRequest{},
- >smodel.Status{},
- >smodel.Application{},
- >smodel.EmailDomainBlock{},
- >smodel.MediaAttachment{},
- }
-
- for _, m := range models {
- if err := db.CreateTable(m); err != nil {
- return fmt.Errorf("error creating table: %s", err)
- }
- }
- return nil
-}
-
-func (m *Module) muxHandler(c *gin.Context) {
- ru := c.Request.RequestURI
- switch c.Request.Method {
- case http.MethodGet:
- if strings.HasPrefix(ru, VerifyPath) {
- m.AccountVerifyGETHandler(c)
- } else {
- m.AccountGETHandler(c)
- }
- case http.MethodPatch:
- if strings.HasPrefix(ru, UpdateCredentialsPath) {
- m.AccountUpdateCredentialsPATCHHandler(c)
- }
- }
-}
diff --git a/internal/apimodule/account/accountcreate.go b/internal/apimodule/account/accountcreate.go
@@ -1,155 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package account
-
-import (
- "errors"
- "fmt"
- "net"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/util"
- "github.com/superseriousbusiness/oauth2/v4"
-)
-
-// AccountCreatePOSTHandler handles create account requests, validates them,
-// and puts them in the database if they're valid.
-// It should be served as a POST at /api/v1/accounts
-func (m *Module) AccountCreatePOSTHandler(c *gin.Context) {
- l := m.log.WithField("func", "accountCreatePOSTHandler")
- authed, err := oauth.MustAuth(c, true, true, false, false)
- if err != nil {
- l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
- return
- }
-
- l.Trace("parsing request form")
- form := &mastotypes.AccountCreateRequest{}
- if err := c.ShouldBind(form); err != nil || form == nil {
- l.Debugf("could not parse form from request: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
- return
- }
-
- l.Tracef("validating form %+v", form)
- if err := validateCreateAccount(form, m.config.AccountsConfig, m.db); err != nil {
- l.Debugf("error validating form: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- clientIP := c.ClientIP()
- l.Tracef("attempting to parse client ip address %s", clientIP)
- signUpIP := net.ParseIP(clientIP)
- if signUpIP == nil {
- l.Debugf("error validating sign up ip address %s", clientIP)
- c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"})
- return
- }
-
- ti, err := m.accountCreate(form, signUpIP, authed.Token, authed.Application)
- if err != nil {
- l.Errorf("internal server error while creating new account: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, ti)
-}
-
-// accountCreate does the dirty work of making an account and user in the database.
-// It then returns a token to the caller, for use with the new account, as per the
-// spec here: https://docs.joinmastodon.org/methods/accounts/
-func (m *Module) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *gtsmodel.Application) (*mastotypes.Token, error) {
- l := m.log.WithField("func", "accountCreate")
-
- // don't store a reason if we don't require one
- reason := form.Reason
- if !m.config.AccountsConfig.ReasonRequired {
- reason = ""
- }
-
- l.Trace("creating new username and account")
- user, err := m.db.NewSignup(form.Username, reason, m.config.AccountsConfig.RequireApproval, form.Email, form.Password, signUpIP, form.Locale, app.ID)
- if err != nil {
- return nil, fmt.Errorf("error creating new signup in the database: %s", err)
- }
-
- l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, app.ID)
- accessToken, err := m.oauthServer.GenerateUserAccessToken(token, app.ClientSecret, user.ID)
- if err != nil {
- return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err)
- }
-
- return &mastotypes.Token{
- AccessToken: accessToken.GetAccess(),
- TokenType: "Bearer",
- Scope: accessToken.GetScope(),
- CreatedAt: accessToken.GetAccessCreateAt().Unix(),
- }, nil
-}
-
-// validateCreateAccount checks through all the necessary prerequisites for creating a new account,
-// according to the provided account create request. If the account isn't eligible, an error will be returned.
-func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.AccountsConfig, database db.DB) error {
- if !c.OpenRegistration {
- return errors.New("registration is not open for this server")
- }
-
- if err := util.ValidateUsername(form.Username); err != nil {
- return err
- }
-
- if err := util.ValidateEmail(form.Email); err != nil {
- return err
- }
-
- if err := util.ValidateNewPassword(form.Password); err != nil {
- return err
- }
-
- if !form.Agreement {
- return errors.New("agreement to terms and conditions not given")
- }
-
- if err := util.ValidateLanguage(form.Locale); err != nil {
- return err
- }
-
- if err := util.ValidateSignUpReason(form.Reason, c.ReasonRequired); err != nil {
- return err
- }
-
- if err := database.IsEmailAvailable(form.Email); err != nil {
- return err
- }
-
- if err := database.IsUsernameAvailable(form.Username); err != nil {
- return err
- }
-
- return nil
-}
diff --git a/internal/apimodule/account/accountget.go b/internal/apimodule/account/accountget.go
@@ -1,57 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package account
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-)
-
-// AccountGETHandler serves the account information held by the server in response to a GET
-// request. It should be served as a GET at /api/v1/accounts/:id.
-//
-// See: https://docs.joinmastodon.org/methods/accounts/
-func (m *Module) AccountGETHandler(c *gin.Context) {
- targetAcctID := c.Param(IDKey)
- if targetAcctID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"})
- return
- }
-
- targetAccount := >smodel.Account{}
- if err := m.db.GetByID(targetAcctID, targetAccount); err != nil {
- if _, ok := err.(db.ErrNoEntries); ok {
- c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, acctInfo)
-}
diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go
@@ -1,260 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package account
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/util"
-)
-
-// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
-// It should be served as a PATCH at /api/v1/accounts/update_credentials
-//
-// TODO: this can be optimized massively by building up a picture of what we want the new account
-// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one
-// which is not gonna make the database very happy when lots of requests are going through.
-// This way it would also be safer because the update won't happen until *all* the fields are validated.
-// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss.
-func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) {
- l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler")
- authed, err := oauth.MustAuth(c, true, false, false, true)
- if err != nil {
- l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
- return
- }
- l.Tracef("retrieved account %+v", authed.Account.ID)
-
- l.Trace("parsing request form")
- form := &mastotypes.UpdateCredentialsRequest{}
- if err := c.ShouldBind(form); err != nil || form == nil {
- l.Debugf("could not parse form from request: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // if everything on the form is nil, then nothing has been set and we shouldn't continue
- if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil {
- l.Debugf("could not parse form from request")
- c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"})
- return
- }
-
- if form.Discoverable != nil {
- if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil {
- l.Debugf("error updating discoverable: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
-
- if form.Bot != nil {
- if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil {
- l.Debugf("error updating bot: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
-
- if form.DisplayName != nil {
- if err := util.ValidateDisplayName(*form.DisplayName); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, >smodel.Account{}); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
-
- if form.Note != nil {
- if err := util.ValidateNote(*form.Note); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, >smodel.Account{}); err != nil {
- l.Debugf("error updating note: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
-
- if form.Avatar != nil && form.Avatar.Size != 0 {
- avatarInfo, err := m.UpdateAccountAvatar(form.Avatar, authed.Account.ID)
- if err != nil {
- l.Debugf("could not update avatar for account %s: %s", authed.Account.ID, err)
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo)
- }
-
- if form.Header != nil && form.Header.Size != 0 {
- headerInfo, err := m.UpdateAccountHeader(form.Header, authed.Account.ID)
- if err != nil {
- l.Debugf("could not update header for account %s: %s", authed.Account.ID, err)
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo)
- }
-
- if form.Locked != nil {
- if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
-
- if form.Source != nil {
- if form.Source.Language != nil {
- if err := util.ValidateLanguage(*form.Source.Language); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
-
- if form.Source.Sensitive != nil {
- if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
-
- if form.Source.Privacy != nil {
- if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
- }
-
- // if form.FieldsAttributes != nil {
- // // TODO: parse fields attributes nicely and update
- // }
-
- // fetch the account with all updated values set
- updatedAccount := >smodel.Account{}
- if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil {
- l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount)
- if err != nil {
- l.Tracef("could not convert account into mastosensitive account: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
- c.JSON(http.StatusOK, acctSensitive)
-}
-
-/*
- HELPER FUNCTIONS
-*/
-
-// TODO: try to combine the below two functions because this is a lot of code repetition.
-
-// UpdateAccountAvatar does the dirty work of checking the avatar part of an account update form,
-// parsing and checking the image, and doing the necessary updates in the database for this to become
-// the account's new avatar image.
-func (m *Module) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
- var err error
- if int(avatar.Size) > m.config.MediaConfig.MaxImageSize {
- err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.config.MediaConfig.MaxImageSize)
- return nil, err
- }
- f, err := avatar.Open()
- if err != nil {
- return nil, fmt.Errorf("could not read provided avatar: %s", err)
- }
-
- // extract the bytes
- buf := new(bytes.Buffer)
- size, err := io.Copy(buf, f)
- if err != nil {
- return nil, fmt.Errorf("could not read provided avatar: %s", err)
- }
- if size == 0 {
- return nil, errors.New("could not read provided avatar: size 0 bytes")
- }
-
- // do the setting
- avatarInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar)
- if err != nil {
- return nil, fmt.Errorf("error processing avatar: %s", err)
- }
-
- return avatarInfo, f.Close()
-}
-
-// UpdateAccountHeader does the dirty work of checking the header part of an account update form,
-// parsing and checking the image, and doing the necessary updates in the database for this to become
-// the account's new header image.
-func (m *Module) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
- var err error
- if int(header.Size) > m.config.MediaConfig.MaxImageSize {
- err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.config.MediaConfig.MaxImageSize)
- return nil, err
- }
- f, err := header.Open()
- if err != nil {
- return nil, fmt.Errorf("could not read provided header: %s", err)
- }
-
- // extract the bytes
- buf := new(bytes.Buffer)
- size, err := io.Copy(buf, f)
- if err != nil {
- return nil, fmt.Errorf("could not read provided header: %s", err)
- }
- if size == 0 {
- return nil, errors.New("could not read provided header: size 0 bytes")
- }
-
- // do the setting
- headerInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader)
- if err != nil {
- return nil, fmt.Errorf("error processing header: %s", err)
- }
-
- return headerInfo, f.Close()
-}
diff --git a/internal/apimodule/account/accountverify.go b/internal/apimodule/account/accountverify.go
@@ -1,50 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package account
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// AccountVerifyGETHandler serves a user's account details to them IF they reached this
-// handler while in possession of a valid token, according to the oauth middleware.
-// It should be served as a GET at /api/v1/accounts/verify_credentials
-func (m *Module) AccountVerifyGETHandler(c *gin.Context) {
- l := m.log.WithField("func", "accountVerifyGETHandler")
- authed, err := oauth.MustAuth(c, true, false, false, true)
- if err != nil {
- l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
- return
- }
-
- l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID)
- acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(authed.Account)
- if err != nil {
- l.Tracef("could not convert account into mastosensitive account: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive)
- c.JSON(http.StatusOK, acctSensitive)
-}
diff --git a/internal/apimodule/account/test/accountcreate_test.go b/internal/apimodule/account/test/accountcreate_test.go
@@ -1,551 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package account
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "io/ioutil"
- "mime/multipart"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/oauth2/v4"
- "github.com/superseriousbusiness/oauth2/v4/models"
- oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
- "golang.org/x/crypto/bcrypt"
-)
-
-type AccountCreateTestSuite struct {
- suite.Suite
- config *config.Config
- log *logrus.Logger
- testAccountLocal *gtsmodel.Account
- testApplication *gtsmodel.Application
- testToken oauth2.TokenInfo
- mockOauthServer *oauth.MockServer
- mockStorage *storage.MockStorage
- mediaHandler media.Handler
- mastoConverter mastotypes.Converter
- db db.DB
- accountModule *account.Module
- newUserFormHappyPath url.Values
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *AccountCreateTestSuite) SetupSuite() {
- // some of our subsequent entities need a log so create this here
- log := logrus.New()
- log.SetLevel(logrus.TraceLevel)
- suite.log = log
-
- suite.testAccountLocal = >smodel.Account{
- ID: uuid.NewString(),
- Username: "test_user",
- }
-
- // can use this test application throughout
- suite.testApplication = >smodel.Application{
- ID: "weeweeeeeeeeeeeeee",
- Name: "a test application",
- Website: "https://some-application-website.com",
- RedirectURI: "http://localhost:8080",
- ClientID: "a-known-client-id",
- ClientSecret: "some-secret",
- Scopes: "read",
- VapidKey: "aaaaaa-aaaaaaaa-aaaaaaaaaaa",
- }
-
- // can use this test token throughout
- suite.testToken = &oauthmodels.Token{
- ClientID: "a-known-client-id",
- RedirectURI: "http://localhost:8080",
- Scope: "read",
- Code: "123456789",
- CodeCreateAt: time.Now(),
- CodeExpiresIn: time.Duration(10 * time.Minute),
- }
-
- // Direct config to local postgres instance
- c := config.Empty()
- c.Protocol = "http"
- c.Host = "localhost"
- c.DBConfig = &config.DBConfig{
- Type: "postgres",
- Address: "localhost",
- Port: 5432,
- User: "postgres",
- Password: "postgres",
- Database: "postgres",
- ApplicationName: "gotosocial",
- }
- c.MediaConfig = &config.MediaConfig{
- MaxImageSize: 2 << 20,
- }
- c.StorageConfig = &config.StorageConfig{
- Backend: "local",
- BasePath: "/tmp",
- ServeProtocol: "http",
- ServeHost: "localhost",
- ServeBasePath: "/fileserver/media",
- }
- suite.config = c
-
- // use an actual database for this, because it's just easier than mocking one out
- database, err := db.New(context.Background(), c, log)
- if err != nil {
- suite.FailNow(err.Error())
- }
- suite.db = database
-
- // we need to mock the oauth server because account creation needs it to create a new token
- suite.mockOauthServer = &oauth.MockServer{}
- suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
- l := suite.log.WithField("func", "GenerateUserAccessToken")
- token := args.Get(0).(oauth2.TokenInfo)
- l.Infof("received token %+v", token)
- clientSecret := args.Get(1).(string)
- l.Infof("received clientSecret %+v", clientSecret)
- userID := args.Get(2).(string)
- l.Infof("received userID %+v", userID)
- }).Return(&models.Token{
- Access: "we're authorized now!",
- }, nil)
-
- suite.mockStorage = &storage.MockStorage{}
- // We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage
- suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil)
-
- // set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar)
- suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log)
-
- suite.mastoConverter = mastotypes.New(suite.config, suite.db)
-
- // and finally here's the thing we're actually testing!
- suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module)
-}
-
-func (suite *AccountCreateTestSuite) TearDownSuite() {
- if err := suite.db.Stop(context.Background()); err != nil {
- logrus.Panicf("error closing db connection: %s", err)
- }
-}
-
-// SetupTest creates a db connection and creates necessary tables before each test
-func (suite *AccountCreateTestSuite) SetupTest() {
- // create all the tables we might need in thie suite
- models := []interface{}{
- >smodel.User{},
- >smodel.Account{},
- >smodel.Follow{},
- >smodel.FollowRequest{},
- >smodel.Status{},
- >smodel.Application{},
- >smodel.EmailDomainBlock{},
- >smodel.MediaAttachment{},
- }
- for _, m := range models {
- if err := suite.db.CreateTable(m); err != nil {
- logrus.Panicf("db connection error: %s", err)
- }
- }
-
- // form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test
- suite.newUserFormHappyPath = url.Values{
- "reason": []string{"a very good reason that's at least 40 characters i swear"},
- "username": []string{"test_user"},
- "email": []string{"user@example.org"},
- "password": []string{"very-strong-password"},
- "agreement": []string{"true"},
- "locale": []string{"en"},
- }
-
- // same with accounts config
- suite.config.AccountsConfig = &config.AccountsConfig{
- OpenRegistration: true,
- RequireApproval: true,
- ReasonRequired: true,
- }
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *AccountCreateTestSuite) TearDownTest() {
-
- // remove all the tables we might have used so it's clear for the next test
- models := []interface{}{
- >smodel.User{},
- >smodel.Account{},
- >smodel.Follow{},
- >smodel.FollowRequest{},
- >smodel.Status{},
- >smodel.Application{},
- >smodel.EmailDomainBlock{},
- >smodel.MediaAttachment{},
- }
- for _, m := range models {
- if err := suite.db.DropTable(m); err != nil {
- logrus.Panicf("error dropping table: %s", err)
- }
- }
-}
-
-/*
- ACTUAL TESTS
-*/
-
-/*
- TESTING: AccountCreatePOSTHandler
-*/
-
-// TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid,
-// and at the end of it a new user and account should be added into the database.
-//
-// This is the handler served at /api/v1/accounts as POST
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = suite.newUserFormHappyPath
- suite.accountModule.AccountCreatePOSTHandler(ctx)
-
- // check response
-
- // 1. we should have OK from our call to the function
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- // 2. we should have a token in the result body
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- t := &mastomodel.Token{}
- err = json.Unmarshal(b, t)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
-
- // check new account
-
- // 1. we should be able to get the new account from the db
- acct := >smodel.Account{}
- err = suite.db.GetWhere("username", "test_user", acct)
- assert.NoError(suite.T(), err)
- assert.NotNil(suite.T(), acct)
- // 2. reason should be set
- assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason)
- // 3. display name should be equal to username by default
- assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName)
- // 4. domain should be nil because this is a local account
- assert.Nil(suite.T(), nil, acct.Domain)
- // 5. id should be set and parseable as a uuid
- assert.NotNil(suite.T(), acct.ID)
- _, err = uuid.Parse(acct.ID)
- assert.Nil(suite.T(), err)
- // 6. private and public key should be set
- assert.NotNil(suite.T(), acct.PrivateKey)
- assert.NotNil(suite.T(), acct.PublicKey)
-
- // check new user
-
- // 1. we should be able to get the new user from the db
- usr := >smodel.User{}
- err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
- assert.Nil(suite.T(), err)
- assert.NotNil(suite.T(), usr)
-
- // 2. user should have account id set to account we got above
- assert.Equal(suite.T(), acct.ID, usr.AccountID)
-
- // 3. id should be set and parseable as a uuid
- assert.NotNil(suite.T(), usr.ID)
- _, err = uuid.Parse(usr.ID)
- assert.Nil(suite.T(), err)
-
- // 4. locale should be equal to what we requested
- assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale)
-
- // 5. created by application id should be equal to the app id
- assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID)
-
- // 6. password should be matcheable to what we set above
- err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password")))
- assert.Nil(suite.T(), err)
-}
-
-// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided:
-// only registered applications can create accounts, and we don't provide one here.
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() {
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = suite.newUserFormHappyPath
- suite.accountModule.AccountCreatePOSTHandler(ctx)
-
- // check response
-
- // 1. we should have forbidden from our call to the function because we didn't auth
- suite.EqualValues(http.StatusForbidden, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all.
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() {
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
- suite.accountModule.AccountCreatePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() {
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = suite.newUserFormHappyPath
- // set a weak password
- ctx.Request.Form.Set("password", "weak")
- suite.accountModule.AccountCreatePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() {
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = suite.newUserFormHappyPath
- // set an invalid locale
- ctx.Request.Form.Set("locale", "neverneverland")
- suite.accountModule.AccountCreatePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() {
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = suite.newUserFormHappyPath
-
- // close registrations
- suite.config.AccountsConfig.OpenRegistration = false
- suite.accountModule.AccountCreatePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() {
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = suite.newUserFormHappyPath
-
- // remove reason
- ctx.Request.Form.Set("reason", "")
-
- suite.accountModule.AccountCreatePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b))
-}
-
-// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required
-func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() {
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = suite.newUserFormHappyPath
-
- // remove reason
- ctx.Request.Form.Set("reason", "just cuz")
-
- suite.accountModule.AccountCreatePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b))
-}
-
-/*
- TESTING: AccountUpdateCredentialsPATCHHandler
-*/
-
-func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
-
- // put test local account in db
- err := suite.db.Put(suite.testAccountLocal)
- assert.NoError(suite.T(), err)
-
- // attach avatar to request
- aviFile, err := os.Open("../../media/test/test-jpeg.jpg")
- assert.NoError(suite.T(), err)
- body := &bytes.Buffer{}
- writer := multipart.NewWriter(body)
-
- part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
- assert.NoError(suite.T(), err)
-
- _, err = io.Copy(part, aviFile)
- assert.NoError(suite.T(), err)
-
- err = aviFile.Close()
- assert.NoError(suite.T(), err)
-
- err = writer.Close()
- assert.NoError(suite.T(), err)
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
- ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
- suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
-
- // check response
-
- // 1. we should have OK because our request was valid
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- // TODO: implement proper checks here
- //
- // b, err := ioutil.ReadAll(result.Body)
- // assert.NoError(suite.T(), err)
- // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
-}
-
-func TestAccountCreateTestSuite(t *testing.T) {
- suite.Run(t, new(AccountCreateTestSuite))
-}
diff --git a/internal/apimodule/account/test/accountupdate_test.go b/internal/apimodule/account/test/accountupdate_test.go
@@ -1,303 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package account
-
-import (
- "bytes"
- "context"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/oauth2/v4"
- "github.com/superseriousbusiness/oauth2/v4/models"
- oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
-)
-
-type AccountUpdateTestSuite struct {
- suite.Suite
- config *config.Config
- log *logrus.Logger
- testAccountLocal *gtsmodel.Account
- testApplication *gtsmodel.Application
- testToken oauth2.TokenInfo
- mockOauthServer *oauth.MockServer
- mockStorage *storage.MockStorage
- mediaHandler media.Handler
- mastoConverter mastotypes.Converter
- db db.DB
- accountModule *account.Module
- newUserFormHappyPath url.Values
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *AccountUpdateTestSuite) SetupSuite() {
- // some of our subsequent entities need a log so create this here
- log := logrus.New()
- log.SetLevel(logrus.TraceLevel)
- suite.log = log
-
- suite.testAccountLocal = >smodel.Account{
- ID: uuid.NewString(),
- Username: "test_user",
- }
-
- // can use this test application throughout
- suite.testApplication = >smodel.Application{
- ID: "weeweeeeeeeeeeeeee",
- Name: "a test application",
- Website: "https://some-application-website.com",
- RedirectURI: "http://localhost:8080",
- ClientID: "a-known-client-id",
- ClientSecret: "some-secret",
- Scopes: "read",
- VapidKey: "aaaaaa-aaaaaaaa-aaaaaaaaaaa",
- }
-
- // can use this test token throughout
- suite.testToken = &oauthmodels.Token{
- ClientID: "a-known-client-id",
- RedirectURI: "http://localhost:8080",
- Scope: "read",
- Code: "123456789",
- CodeCreateAt: time.Now(),
- CodeExpiresIn: time.Duration(10 * time.Minute),
- }
-
- // Direct config to local postgres instance
- c := config.Empty()
- c.Protocol = "http"
- c.Host = "localhost"
- c.DBConfig = &config.DBConfig{
- Type: "postgres",
- Address: "localhost",
- Port: 5432,
- User: "postgres",
- Password: "postgres",
- Database: "postgres",
- ApplicationName: "gotosocial",
- }
- c.MediaConfig = &config.MediaConfig{
- MaxImageSize: 2 << 20,
- }
- c.StorageConfig = &config.StorageConfig{
- Backend: "local",
- BasePath: "/tmp",
- ServeProtocol: "http",
- ServeHost: "localhost",
- ServeBasePath: "/fileserver/media",
- }
- suite.config = c
-
- // use an actual database for this, because it's just easier than mocking one out
- database, err := db.New(context.Background(), c, log)
- if err != nil {
- suite.FailNow(err.Error())
- }
- suite.db = database
-
- // we need to mock the oauth server because account creation needs it to create a new token
- suite.mockOauthServer = &oauth.MockServer{}
- suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
- l := suite.log.WithField("func", "GenerateUserAccessToken")
- token := args.Get(0).(oauth2.TokenInfo)
- l.Infof("received token %+v", token)
- clientSecret := args.Get(1).(string)
- l.Infof("received clientSecret %+v", clientSecret)
- userID := args.Get(2).(string)
- l.Infof("received userID %+v", userID)
- }).Return(&models.Token{
- Code: "we're authorized now!",
- }, nil)
-
- suite.mockStorage = &storage.MockStorage{}
- // We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage
- suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil)
-
- // set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar)
- suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log)
-
- suite.mastoConverter = mastotypes.New(suite.config, suite.db)
-
- // and finally here's the thing we're actually testing!
- suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module)
-}
-
-func (suite *AccountUpdateTestSuite) TearDownSuite() {
- if err := suite.db.Stop(context.Background()); err != nil {
- logrus.Panicf("error closing db connection: %s", err)
- }
-}
-
-// SetupTest creates a db connection and creates necessary tables before each test
-func (suite *AccountUpdateTestSuite) SetupTest() {
- // create all the tables we might need in thie suite
- models := []interface{}{
- >smodel.User{},
- >smodel.Account{},
- >smodel.Follow{},
- >smodel.FollowRequest{},
- >smodel.Status{},
- >smodel.Application{},
- >smodel.EmailDomainBlock{},
- >smodel.MediaAttachment{},
- }
- for _, m := range models {
- if err := suite.db.CreateTable(m); err != nil {
- logrus.Panicf("db connection error: %s", err)
- }
- }
-
- // form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test
- suite.newUserFormHappyPath = url.Values{
- "reason": []string{"a very good reason that's at least 40 characters i swear"},
- "username": []string{"test_user"},
- "email": []string{"user@example.org"},
- "password": []string{"very-strong-password"},
- "agreement": []string{"true"},
- "locale": []string{"en"},
- }
-
- // same with accounts config
- suite.config.AccountsConfig = &config.AccountsConfig{
- OpenRegistration: true,
- RequireApproval: true,
- ReasonRequired: true,
- }
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *AccountUpdateTestSuite) TearDownTest() {
-
- // remove all the tables we might have used so it's clear for the next test
- models := []interface{}{
- >smodel.User{},
- >smodel.Account{},
- >smodel.Follow{},
- >smodel.FollowRequest{},
- >smodel.Status{},
- >smodel.Application{},
- >smodel.EmailDomainBlock{},
- >smodel.MediaAttachment{},
- }
- for _, m := range models {
- if err := suite.db.DropTable(m); err != nil {
- logrus.Panicf("error dropping table: %s", err)
- }
- }
-}
-
-/*
- ACTUAL TESTS
-*/
-
-/*
- TESTING: AccountUpdateCredentialsPATCHHandler
-*/
-
-func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() {
-
- // put test local account in db
- err := suite.db.Put(suite.testAccountLocal)
- assert.NoError(suite.T(), err)
-
- // attach avatar to request form
- avatarFile, err := os.Open("../../media/test/test-jpeg.jpg")
- assert.NoError(suite.T(), err)
- body := &bytes.Buffer{}
- writer := multipart.NewWriter(body)
-
- avatarPart, err := writer.CreateFormFile("avatar", "test-jpeg.jpg")
- assert.NoError(suite.T(), err)
-
- _, err = io.Copy(avatarPart, avatarFile)
- assert.NoError(suite.T(), err)
-
- err = avatarFile.Close()
- assert.NoError(suite.T(), err)
-
- // set display name to a new value
- displayNamePart, err := writer.CreateFormField("display_name")
- assert.NoError(suite.T(), err)
-
- _, err = io.Copy(displayNamePart, bytes.NewBufferString("test_user_wohoah"))
- assert.NoError(suite.T(), err)
-
- // set locked to true
- lockedPart, err := writer.CreateFormField("locked")
- assert.NoError(suite.T(), err)
-
- _, err = io.Copy(lockedPart, bytes.NewBufferString("true"))
- assert.NoError(suite.T(), err)
-
- // close the request writer, the form is now prepared
- err = writer.Close()
- assert.NoError(suite.T(), err)
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal)
- ctx.Set(oauth.SessionAuthorizedToken, suite.testToken)
- ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting
- ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
- suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx)
-
- // check response
-
- // 1. we should have OK because our request was valid
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- // 2. we should have an error message in the result body
- result := recorder.Result()
- defer result.Body.Close()
- // TODO: implement proper checks here
- //
- // b, err := ioutil.ReadAll(result.Body)
- // assert.NoError(suite.T(), err)
- // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b))
-}
-
-func TestAccountUpdateTestSuite(t *testing.T) {
- suite.Run(t, new(AccountUpdateTestSuite))
-}
diff --git a/internal/apimodule/account/test/accountverify_test.go b/internal/apimodule/account/test/accountverify_test.go
@@ -1,19 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package account
diff --git a/internal/apimodule/admin/admin.go b/internal/apimodule/admin/admin.go
@@ -1,88 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package admin
-
-import (
- "fmt"
- "net/http"
-
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-const (
- // BasePath is the base API path for this module
- BasePath = "/api/v1/admin"
- // EmojiPath is used for posting/deleting custom emojis
- EmojiPath = BasePath + "/custom_emojis"
-)
-
-// Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc)
-type Module struct {
- config *config.Config
- db db.DB
- mediaHandler media.Handler
- mastoConverter mastotypes.Converter
- log *logrus.Logger
-}
-
-// New returns a new admin module
-func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
- return &Module{
- config: config,
- db: db,
- mediaHandler: mediaHandler,
- mastoConverter: mastoConverter,
- log: log,
- }
-}
-
-// Route attaches all routes from this module to the given router
-func (m *Module) Route(r router.Router) error {
- r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler)
- return nil
-}
-
-// CreateTables creates the necessary tables for this module in the given database
-func (m *Module) CreateTables(db db.DB) error {
- models := []interface{}{
- >smodel.User{},
- >smodel.Account{},
- >smodel.Follow{},
- >smodel.FollowRequest{},
- >smodel.Status{},
- >smodel.Application{},
- >smodel.EmailDomainBlock{},
- >smodel.MediaAttachment{},
- >smodel.Emoji{},
- }
-
- for _, m := range models {
- if err := db.CreateTable(m); err != nil {
- return fmt.Errorf("error creating table: %s", err)
- }
- }
- return nil
-}
diff --git a/internal/apimodule/admin/emojicreate.go b/internal/apimodule/admin/emojicreate.go
@@ -1,130 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package admin
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/util"
-)
-
-func (m *Module) emojiCreatePOSTHandler(c *gin.Context) {
- l := m.log.WithFields(logrus.Fields{
- "func": "emojiCreatePOSTHandler",
- "request_uri": c.Request.RequestURI,
- "user_agent": c.Request.UserAgent(),
- "origin_ip": c.ClientIP(),
- })
-
- // make sure we're authed with an admin account
- authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
- if err != nil {
- l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
- return
- }
- if !authed.User.Admin {
- l.Debugf("user %s not an admin", authed.User.ID)
- c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"})
- return
- }
-
- // extract the media create form from the request context
- l.Tracef("parsing request form: %+v", c.Request.Form)
- form := &mastotypes.EmojiCreateRequest{}
- if err := c.ShouldBind(form); err != nil {
- l.Debugf("error parsing form %+v: %s", c.Request.Form, err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)})
- return
- }
-
- // Give the fields on the request form a first pass to make sure the request is superficially valid.
- l.Tracef("validating form %+v", form)
- if err := validateCreateEmoji(form); err != nil {
- l.Debugf("error validating form: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // open the emoji and extract the bytes from it
- f, err := form.Image.Open()
- if err != nil {
- l.Debugf("error opening emoji: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)})
- return
- }
- buf := new(bytes.Buffer)
- size, err := io.Copy(buf, f)
- if err != nil {
- l.Debugf("error reading emoji: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)})
- return
- }
- if size == 0 {
- l.Debug("could not read provided emoji: size 0 bytes")
- c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"})
- return
- }
-
- // allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using
- emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode)
- if err != nil {
- l.Debugf("error reading emoji: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)})
- return
- }
-
- mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji)
- if err != nil {
- l.Debugf("error converting emoji to mastotype: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)})
- return
- }
-
- if err := m.db.Put(emoji); err != nil {
- l.Debugf("database error while processing emoji: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)})
- return
- }
-
- c.JSON(http.StatusOK, mastoEmoji)
-}
-
-func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error {
- // check there actually is an image attached and it's not size 0
- if form.Image == nil || form.Image.Size == 0 {
- return errors.New("no emoji given")
- }
-
- // a very superficial check to see if the media size limit is exceeded
- if form.Image.Size > media.EmojiMaxBytes {
- return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size)
- }
-
- return util.ValidateEmojiShortcode(form.Shortcode)
-}
diff --git a/internal/apimodule/apimodule.go b/internal/apimodule/apimodule.go
@@ -1,33 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-// Package apimodule is basically a wrapper for a lot of modules (in subdirectories) that satisfy the ClientAPIModule interface.
-package apimodule
-
-import (
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set
-// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;)
-// A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/
-type ClientAPIModule interface {
- Route(s router.Router) error
- CreateTables(db db.DB) error
-}
diff --git a/internal/apimodule/app/app.go b/internal/apimodule/app/app.go
@@ -1,77 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package app
-
-import (
- "fmt"
- "net/http"
-
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-// BasePath is the base path for this api module
-const BasePath = "/api/v1/apps"
-
-// Module implements the ClientAPIModule interface for requests relating to registering/removing applications
-type Module struct {
- server oauth.Server
- db db.DB
- mastoConverter mastotypes.Converter
- log *logrus.Logger
-}
-
-// New returns a new auth module
-func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
- return &Module{
- server: srv,
- db: db,
- mastoConverter: mastoConverter,
- log: log,
- }
-}
-
-// Route satisfies the RESTAPIModule interface
-func (m *Module) Route(s router.Router) error {
- s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler)
- return nil
-}
-
-// CreateTables creates the necessary tables for this module in the given database
-func (m *Module) CreateTables(db db.DB) error {
- models := []interface{}{
- &oauth.Client{},
- &oauth.Token{},
- >smodel.User{},
- >smodel.Account{},
- >smodel.Application{},
- }
-
- for _, m := range models {
- if err := db.CreateTable(m); err != nil {
- return fmt.Errorf("error creating table: %s", err)
- }
- }
- return nil
-}
diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go
@@ -1,119 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package app
-
-import (
- "fmt"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// AppsPOSTHandler should be served at https://example.org/api/v1/apps
-// It is equivalent to: https://docs.joinmastodon.org/methods/apps/
-func (m *Module) AppsPOSTHandler(c *gin.Context) {
- l := m.log.WithField("func", "AppsPOSTHandler")
- l.Trace("entering AppsPOSTHandler")
-
- form := &mastotypes.ApplicationPOSTRequest{}
- if err := c.ShouldBind(form); err != nil {
- c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
- return
- }
-
- // permitted length for most fields
- permittedLength := 64
- // redirect can be a bit bigger because we probably need to encode data in the redirect uri
- permittedRedirect := 256
-
- // check lengths of fields before proceeding so the user can't spam huge entries into the database
- if len(form.ClientName) > permittedLength {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", permittedLength)})
- return
- }
- if len(form.Website) > permittedLength {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", permittedLength)})
- return
- }
- if len(form.RedirectURIs) > permittedRedirect {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", permittedRedirect)})
- return
- }
- if len(form.Scopes) > permittedLength {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", permittedLength)})
- return
- }
-
- // set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/
- var scopes string
- if form.Scopes == "" {
- scopes = "read"
- } else {
- scopes = form.Scopes
- }
-
- // generate new IDs for this application and its associated client
- clientID := uuid.NewString()
- clientSecret := uuid.NewString()
- vapidKey := uuid.NewString()
-
- // generate the application to put in the database
- app := >smodel.Application{
- Name: form.ClientName,
- Website: form.Website,
- RedirectURI: form.RedirectURIs,
- ClientID: clientID,
- ClientSecret: clientSecret,
- Scopes: scopes,
- VapidKey: vapidKey,
- }
-
- // chuck it in the db
- if err := m.db.Put(app); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- // now we need to model an oauth client from the application that the oauth library can use
- oc := &oauth.Client{
- ID: clientID,
- Secret: clientSecret,
- Domain: form.RedirectURIs,
- UserID: "", // This client isn't yet associated with a specific user, it's just an app client right now
- }
-
- // chuck it in the db
- if err := m.db.Put(oc); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- mastoApp, err := m.mastoConverter.AppToMastoSensitive(app)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- // done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
- c.JSON(http.StatusOK, mastoApp)
-}
diff --git a/internal/apimodule/app/test/app_test.go b/internal/apimodule/app/test/app_test.go
@@ -1,21 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package app
-
-// TODO: write tests
diff --git a/internal/apimodule/auth/auth.go b/internal/apimodule/auth/auth.go
@@ -1,88 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package auth
-
-import (
- "fmt"
- "net/http"
-
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-const (
- // AuthSignInPath is the API path for users to sign in through
- AuthSignInPath = "/auth/sign_in"
- // OauthTokenPath is the API path to use for granting token requests to users with valid credentials
- OauthTokenPath = "/oauth/token"
- // OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user)
- OauthAuthorizePath = "/oauth/authorize"
-)
-
-// Module implements the ClientAPIModule interface for
-type Module struct {
- server oauth.Server
- db db.DB
- log *logrus.Logger
-}
-
-// New returns a new auth module
-func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule {
- return &Module{
- server: srv,
- db: db,
- log: log,
- }
-}
-
-// Route satisfies the RESTAPIModule interface
-func (m *Module) Route(s router.Router) error {
- s.AttachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler)
- s.AttachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler)
-
- s.AttachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler)
-
- s.AttachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler)
- s.AttachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler)
-
- s.AttachMiddleware(m.OauthTokenMiddleware)
- return nil
-}
-
-// CreateTables creates the necessary tables for this module in the given database
-func (m *Module) CreateTables(db db.DB) error {
- models := []interface{}{
- &oauth.Client{},
- &oauth.Token{},
- >smodel.User{},
- >smodel.Account{},
- >smodel.Application{},
- }
-
- for _, m := range models {
- if err := db.CreateTable(m); err != nil {
- return fmt.Errorf("error creating table: %s", err)
- }
- }
- return nil
-}
diff --git a/internal/apimodule/auth/authorize.go b/internal/apimodule/auth/authorize.go
@@ -1,204 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package auth
-
-import (
- "errors"
- "fmt"
- "net/http"
- "net/url"
-
- "github.com/gin-contrib/sessions"
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-)
-
-// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize
-// The idea here is to present an oauth authorize page to the user, with a button
-// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
-func (m *Module) AuthorizeGETHandler(c *gin.Context) {
- l := m.log.WithField("func", "AuthorizeGETHandler")
- s := sessions.Default(c)
-
- // UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow
- // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page.
- userID, ok := s.Get("userid").(string)
- if !ok || userID == "" {
- l.Trace("userid was empty, parsing form then redirecting to sign in page")
- if err := parseAuthForm(c, l); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- } else {
- c.Redirect(http.StatusFound, AuthSignInPath)
- }
- return
- }
-
- // We can use the client_id on the session to retrieve info about the app associated with the client_id
- clientID, ok := s.Get("client_id").(string)
- if !ok || clientID == "" {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"})
- return
- }
- app := >smodel.Application{
- ClientID: clientID,
- }
- if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)})
- return
- }
-
- // we can also use the userid of the user to fetch their username from the db to greet them nicely <3
- user := >smodel.User{
- ID: userID,
- }
- if err := m.db.GetByID(user.ID, user); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- acct := >smodel.Account{
- ID: user.AccountID,
- }
-
- if err := m.db.GetByID(acct.ID, acct); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- // Finally we should also get the redirect and scope of this particular request, as stored in the session.
- redirect, ok := s.Get("redirect_uri").(string)
- if !ok || redirect == "" {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"})
- return
- }
- scope, ok := s.Get("scope").(string)
- if !ok || scope == "" {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"})
- return
- }
-
- // the authorize template will display a form to the user where they can get some information
- // about the app that's trying to authorize, and the scope of the request.
- // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler
- l.Trace("serving authorize html")
- c.HTML(http.StatusOK, "authorize.tmpl", gin.H{
- "appname": app.Name,
- "appwebsite": app.Website,
- "redirect": redirect,
- "scope": scope,
- "user": acct.Username,
- })
-}
-
-// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize
-// At this point we assume that the user has A) logged in and B) accepted that the app should act for them,
-// so we should proceed with the authentication flow and generate an oauth token for them if we can.
-// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user
-func (m *Module) AuthorizePOSTHandler(c *gin.Context) {
- l := m.log.WithField("func", "AuthorizePOSTHandler")
- s := sessions.Default(c)
-
- // At this point we know the user has said 'yes' to allowing the application and oauth client
- // work for them, so we can set the
-
- // We need to retrieve the original form submitted to the authorizeGEThandler, and
- // recreate it on the request so that it can be used further by the oauth2 library.
- // So first fetch all the values from the session.
- forceLogin, ok := s.Get("force_login").(string)
- if !ok {
- c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"})
- return
- }
- responseType, ok := s.Get("response_type").(string)
- if !ok || responseType == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"})
- return
- }
- clientID, ok := s.Get("client_id").(string)
- if !ok || clientID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"})
- return
- }
- redirectURI, ok := s.Get("redirect_uri").(string)
- if !ok || redirectURI == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"})
- return
- }
- scope, ok := s.Get("scope").(string)
- if !ok {
- c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"})
- return
- }
- userID, ok := s.Get("userid").(string)
- if !ok {
- c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"})
- return
- }
- // we're done with the session so we can clear it now
- s.Clear()
-
- // now set the values on the request
- values := url.Values{}
- values.Set("force_login", forceLogin)
- values.Set("response_type", responseType)
- values.Set("client_id", clientID)
- values.Set("redirect_uri", redirectURI)
- values.Set("scope", scope)
- values.Set("userid", userID)
- c.Request.Form = values
- l.Tracef("values on request set to %+v", c.Request.Form)
-
- // and proceed with authorization using the oauth2 library
- if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- }
-}
-
-// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores
-// the values in the form into the session.
-func parseAuthForm(c *gin.Context, l *logrus.Entry) error {
- s := sessions.Default(c)
-
- // first make sure they've filled out the authorize form with the required values
- form := &mastotypes.OAuthAuthorize{}
- if err := c.ShouldBind(form); err != nil {
- return err
- }
- l.Tracef("parsed form: %+v", form)
-
- // these fields are *required* so check 'em
- if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" {
- return errors.New("missing one of: response_type, client_id or redirect_uri")
- }
-
- // set default scope to read
- if form.Scope == "" {
- form.Scope = "read"
- }
-
- // save these values from the form so we can use them elsewhere in the session
- s.Set("force_login", form.ForceLogin)
- s.Set("response_type", form.ResponseType)
- s.Set("client_id", form.ClientID)
- s.Set("redirect_uri", form.RedirectURI)
- s.Set("scope", form.Scope)
- return s.Save()
-}
diff --git a/internal/apimodule/auth/middleware.go b/internal/apimodule/auth/middleware.go
@@ -1,76 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package auth
-
-import (
- "github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// OauthTokenMiddleware checks if the client has presented a valid oauth Bearer token.
-// If so, it will check the User that the token belongs to, and set that in the context of
-// the request. Then, it will look up the account for that user, and set that in the request too.
-// If user or account can't be found, then the handler won't *fail*, in case the server wants to allow
-// public requests that don't have a Bearer token set (eg., for public instance information and so on).
-func (m *Module) OauthTokenMiddleware(c *gin.Context) {
- l := m.log.WithField("func", "ValidatePassword")
- l.Trace("entering OauthTokenMiddleware")
-
- ti, err := m.server.ValidationBearerToken(c.Request)
- if err != nil {
- l.Trace("no valid token presented: continuing with unauthenticated request")
- return
- }
- c.Set(oauth.SessionAuthorizedToken, ti)
- l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedToken, ti)
-
- // check for user-level token
- if uid := ti.GetUserID(); uid != "" {
- l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope())
-
- // fetch user's and account for this user id
- user := >smodel.User{}
- if err := m.db.GetByID(uid, user); err != nil || user == nil {
- l.Warnf("no user found for validated uid %s", uid)
- return
- }
- c.Set(oauth.SessionAuthorizedUser, user)
- l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user)
-
- acct := >smodel.Account{}
- if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil {
- l.Warnf("no account found for validated user %s", uid)
- return
- }
- c.Set(oauth.SessionAuthorizedAccount, acct)
- l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct)
- }
-
- // check for application token
- if cid := ti.GetClientID(); cid != "" {
- l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope())
- app := >smodel.Application{}
- if err := m.db.GetWhere("client_id", cid, app); err != nil {
- l.Tracef("no app found for client %s", cid)
- }
- c.Set(oauth.SessionAuthorizedApplication, app)
- l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app)
- }
-}
diff --git a/internal/apimodule/auth/signin.go b/internal/apimodule/auth/signin.go
@@ -1,116 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package auth
-
-import (
- "errors"
- "net/http"
-
- "github.com/gin-contrib/sessions"
- "github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "golang.org/x/crypto/bcrypt"
-)
-
-// login just wraps a form-submitted username (we want an email) and password
-type login struct {
- Email string `form:"username"`
- Password string `form:"password"`
-}
-
-// SignInGETHandler should be served at https://example.org/auth/sign_in.
-// The idea is to present a sign in page to the user, where they can enter their username and password.
-// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler
-func (m *Module) SignInGETHandler(c *gin.Context) {
- m.log.WithField("func", "SignInGETHandler").Trace("serving sign in html")
- c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{})
-}
-
-// SignInPOSTHandler should be served at https://example.org/auth/sign_in.
-// The idea is to present a sign in page to the user, where they can enter their username and password.
-// The handler will then redirect to the auth handler served at /auth
-func (m *Module) SignInPOSTHandler(c *gin.Context) {
- l := m.log.WithField("func", "SignInPOSTHandler")
- s := sessions.Default(c)
- form := &login{}
- if err := c.ShouldBind(form); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- l.Tracef("parsed form: %+v", form)
-
- userid, err := m.ValidatePassword(form.Email, form.Password)
- if err != nil {
- c.String(http.StatusForbidden, err.Error())
- return
- }
-
- s.Set("userid", userid)
- if err := s.Save(); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- l.Trace("redirecting to auth page")
- c.Redirect(http.StatusFound, OauthAuthorizePath)
-}
-
-// ValidatePassword takes an email address and a password.
-// The goal is to authenticate the password against the one for that email
-// address stored in the database. If OK, we return the userid (a uuid) for that user,
-// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db.
-func (m *Module) ValidatePassword(email string, password string) (userid string, err error) {
- l := m.log.WithField("func", "ValidatePassword")
-
- // make sure an email/password was provided and bail if not
- if email == "" || password == "" {
- l.Debug("email or password was not provided")
- return incorrectPassword()
- }
-
- // first we select the user from the database based on email address, bail if no user found for that email
- gtsUser := >smodel.User{}
-
- if err := m.db.GetWhere("email", email, gtsUser); err != nil {
- l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
- return incorrectPassword()
- }
-
- // make sure a password is actually set and bail if not
- if gtsUser.EncryptedPassword == "" {
- l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email)
- return incorrectPassword()
- }
-
- // compare the provided password with the encrypted one from the db, bail if they don't match
- if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil {
- l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err)
- return incorrectPassword()
- }
-
- // If we've made it this far the email/password is correct, so we can just return the id of the user.
- userid = gtsUser.ID
- l.Tracef("returning (%s, %s)", userid, err)
- return
-}
-
-// incorrectPassword is just a little helper function to use in the ValidatePassword function
-func incorrectPassword() (string, error) {
- return "", errors.New("password/email combination was incorrect")
-}
diff --git a/internal/apimodule/auth/test/auth_test.go b/internal/apimodule/auth/test/auth_test.go
@@ -1,166 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package auth
-
-import (
- "context"
- "fmt"
- "testing"
-
- "github.com/google/uuid"
- "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/oauth"
- "golang.org/x/crypto/bcrypt"
-)
-
-type AuthTestSuite struct {
- suite.Suite
- oauthServer oauth.Server
- db db.DB
- testAccount *gtsmodel.Account
- testApplication *gtsmodel.Application
- testUser *gtsmodel.User
- testClient *oauth.Client
- config *config.Config
-}
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *AuthTestSuite) SetupSuite() {
- c := config.Empty()
- // we're running on localhost without https so set the protocol to http
- c.Protocol = "http"
- // just for testing
- c.Host = "localhost:8080"
- // because go tests are run within the test package directory, we need to fiddle with the templateconfig
- // basedir in a way that we wouldn't normally have to do when running the binary, in order to make
- // the templates actually load
- c.TemplateConfig.BaseDir = "../../../web/template/"
- c.DBConfig = &config.DBConfig{
- Type: "postgres",
- Address: "localhost",
- Port: 5432,
- User: "postgres",
- Password: "postgres",
- Database: "postgres",
- ApplicationName: "gotosocial",
- }
- suite.config = c
-
- encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
- if err != nil {
- logrus.Panicf("error encrypting user pass: %s", err)
- }
-
- acctID := uuid.NewString()
-
- suite.testAccount = >smodel.Account{
- ID: acctID,
- Username: "test_user",
- }
- suite.testUser = >smodel.User{
- EncryptedPassword: string(encryptedPassword),
- Email: "user@example.org",
- AccountID: acctID,
- }
- suite.testClient = &oauth.Client{
- ID: "a-known-client-id",
- Secret: "some-secret",
- Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host),
- }
- suite.testApplication = >smodel.Application{
- Name: "a test application",
- Website: "https://some-application-website.com",
- RedirectURI: "http://localhost:8080",
- ClientID: "a-known-client-id",
- ClientSecret: "some-secret",
- Scopes: "read",
- VapidKey: uuid.NewString(),
- }
-}
-
-// SetupTest creates a postgres connection and creates the oauth_clients table before each test
-func (suite *AuthTestSuite) SetupTest() {
-
- log := logrus.New()
- log.SetLevel(logrus.TraceLevel)
- db, err := db.New(context.Background(), suite.config, log)
- if err != nil {
- logrus.Panicf("error creating database connection: %s", err)
- }
-
- suite.db = db
-
- models := []interface{}{
- &oauth.Client{},
- &oauth.Token{},
- >smodel.User{},
- >smodel.Account{},
- >smodel.Application{},
- }
-
- for _, m := range models {
- if err := suite.db.CreateTable(m); err != nil {
- logrus.Panicf("db connection error: %s", err)
- }
- }
-
- suite.oauthServer = oauth.New(suite.db, log)
-
- if err := suite.db.Put(suite.testAccount); err != nil {
- logrus.Panicf("could not insert test account into db: %s", err)
- }
- if err := suite.db.Put(suite.testUser); err != nil {
- logrus.Panicf("could not insert test user into db: %s", err)
- }
- if err := suite.db.Put(suite.testClient); err != nil {
- logrus.Panicf("could not insert test client into db: %s", err)
- }
- if err := suite.db.Put(suite.testApplication); err != nil {
- logrus.Panicf("could not insert test application into db: %s", err)
- }
-
-}
-
-// TearDownTest drops the oauth_clients table and closes the pg connection after each test
-func (suite *AuthTestSuite) TearDownTest() {
- models := []interface{}{
- &oauth.Client{},
- &oauth.Token{},
- >smodel.User{},
- >smodel.Account{},
- >smodel.Application{},
- }
- for _, m := range models {
- if err := suite.db.DropTable(m); err != nil {
- logrus.Panicf("error dropping table: %s", err)
- }
- }
- if err := suite.db.Stop(context.Background()); err != nil {
- logrus.Panicf("error closing db connection: %s", err)
- }
- suite.db = nil
-}
-
-func TestAuthTestSuite(t *testing.T) {
- suite.Run(t, new(AuthTestSuite))
-}
diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go
@@ -1,84 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package fileserver
-
-import (
- "fmt"
- "net/http"
-
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/router"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
-)
-
-const (
- // AccountIDKey is the url key for account id (an account uuid)
- AccountIDKey = "account_id"
- // MediaTypeKey is the url key for media type (usually something like attachment or header etc)
- MediaTypeKey = "media_type"
- // MediaSizeKey is the url key for the desired media size--original/small/static
- MediaSizeKey = "media_size"
- // FileNameKey is the actual filename being sought. Will usually be a UUID then something like .jpeg
- FileNameKey = "file_name"
-)
-
-// FileServer implements the RESTAPIModule interface.
-// The goal here is to serve requested media files if the gotosocial server is configured to use local storage.
-type FileServer struct {
- config *config.Config
- db db.DB
- storage storage.Storage
- log *logrus.Logger
- storageBase string
-}
-
-// New returns a new fileServer module
-func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule {
- return &FileServer{
- config: config,
- db: db,
- storage: storage,
- log: log,
- storageBase: config.StorageConfig.ServeBasePath,
- }
-}
-
-// Route satisfies the RESTAPIModule interface
-func (m *FileServer) Route(s router.Router) error {
- s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile)
- return nil
-}
-
-// CreateTables populates necessary tables in the given DB
-func (m *FileServer) CreateTables(db db.DB) error {
- models := []interface{}{
- >smodel.MediaAttachment{},
- }
-
- for _, m := range models {
- if err := db.CreateTable(m); err != nil {
- return fmt.Errorf("error creating table: %s", err)
- }
- }
- return nil
-}
diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go
@@ -1,243 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package fileserver
-
-import (
- "bytes"
- "net/http"
- "strings"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
-)
-
-// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
-//
-// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
-// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
-func (m *FileServer) ServeFile(c *gin.Context) {
- l := m.log.WithFields(logrus.Fields{
- "func": "ServeFile",
- "request_uri": c.Request.RequestURI,
- "user_agent": c.Request.UserAgent(),
- "origin_ip": c.ClientIP(),
- })
- l.Trace("received request")
-
- // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
- // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
- // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
- accountID := c.Param(AccountIDKey)
- if accountID == "" {
- l.Debug("missing accountID from request")
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- mediaType := c.Param(MediaTypeKey)
- if mediaType == "" {
- l.Debug("missing mediaType from request")
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- mediaSize := c.Param(MediaSizeKey)
- if mediaSize == "" {
- l.Debug("missing mediaSize from request")
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- fileName := c.Param(FileNameKey)
- if fileName == "" {
- l.Debug("missing fileName from request")
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // Only serve media types that are defined in our internal media module
- switch mediaType {
- case media.MediaHeader, media.MediaAvatar, media.MediaAttachment:
- m.serveAttachment(c, accountID, mediaType, mediaSize, fileName)
- return
- case media.MediaEmoji:
- m.serveEmoji(c, accountID, mediaType, mediaSize, fileName)
- return
- }
- l.Debugf("mediatype %s not recognized", mediaType)
- c.String(http.StatusNotFound, "404 page not found")
-}
-
-func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) {
- l := m.log.WithFields(logrus.Fields{
- "func": "serveAttachment",
- "request_uri": c.Request.RequestURI,
- "user_agent": c.Request.UserAgent(),
- "origin_ip": c.ClientIP(),
- })
-
- // This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static
- switch mediaSize {
- case media.MediaOriginal, media.MediaSmall, media.MediaStatic:
- default:
- l.Debugf("mediasize %s not recognized", mediaSize)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // derive the media id and the file extension from the last part of the request
- spl := strings.Split(fileName, ".")
- if len(spl) != 2 {
- l.Debugf("filename %s not parseable", fileName)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
- wantedMediaID := spl[0]
- fileExtension := spl[1]
- if wantedMediaID == "" || fileExtension == "" {
- l.Debugf("filename %s not parseable", fileName)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
- attachment := >smodel.MediaAttachment{}
- if err := m.db.GetByID(wantedMediaID, attachment); err != nil {
- l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // make sure the given account id owns the requested attachment
- if accountID != attachment.AccountID {
- l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
- var storagePath string
- var contentType string
- var contentLength int
- switch mediaSize {
- case media.MediaOriginal:
- storagePath = attachment.File.Path
- contentType = attachment.File.ContentType
- contentLength = attachment.File.FileSize
- case media.MediaSmall:
- storagePath = attachment.Thumbnail.Path
- contentType = attachment.Thumbnail.ContentType
- contentLength = attachment.Thumbnail.FileSize
- }
-
- // use the path listed on the attachment we pulled out of the database to retrieve the object from storage
- attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath)
- if err != nil {
- l.Debugf("error retrieving from storage: %s", err)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes)))
-
- // finally we can return with all the information we derived above
- c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{})
-}
-
-func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) {
- l := m.log.WithFields(logrus.Fields{
- "func": "serveEmoji",
- "request_uri": c.Request.RequestURI,
- "user_agent": c.Request.UserAgent(),
- "origin_ip": c.ClientIP(),
- })
-
- // This corresponds to original-sized emoji as it was uploaded, or static
- switch mediaSize {
- case media.MediaOriginal, media.MediaStatic:
- default:
- l.Debugf("mediasize %s not recognized", mediaSize)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // derive the media id and the file extension from the last part of the request
- spl := strings.Split(fileName, ".")
- if len(spl) != 2 {
- l.Debugf("filename %s not parseable", fileName)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
- wantedEmojiID := spl[0]
- fileExtension := spl[1]
- if wantedEmojiID == "" || fileExtension == "" {
- l.Debugf("filename %s not parseable", fileName)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
- emoji := >smodel.Emoji{}
- if err := m.db.GetByID(wantedEmojiID, emoji); err != nil {
- l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // make sure the instance account id owns the requested emoji
- instanceAccount := >smodel.Account{}
- if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil {
- l.Debugf("error fetching instance account: %s", err)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
- if accountID != instanceAccount.ID {
- l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment
- var storagePath string
- var contentType string
- var contentLength int
- switch mediaSize {
- case media.MediaOriginal:
- storagePath = emoji.ImagePath
- contentType = emoji.ImageContentType
- contentLength = emoji.ImageFileSize
- case media.MediaStatic:
- storagePath = emoji.ImageStaticPath
- contentType = "image/png"
- contentLength = emoji.ImageStaticFileSize
- }
-
- // use the path listed on the emoji we pulled out of the database to retrieve the object from storage
- emojiBytes, err := m.storage.RetrieveFileFrom(storagePath)
- if err != nil {
- l.Debugf("error retrieving emoji from storage: %s", err)
- c.String(http.StatusNotFound, "404 page not found")
- return
- }
-
- // finally we can return with all the information we derived above
- c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{})
-}
diff --git a/internal/apimodule/fileserver/test/servefile_test.go b/internal/apimodule/fileserver/test/servefile_test.go
@@ -1,157 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package test
-
-import (
- "context"
- "fmt"
- "io/ioutil"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/testrig"
-)
-
-type ServeFileTestSuite struct {
- // standard suite interfaces
- suite.Suite
- config *config.Config
- db db.DB
- log *logrus.Logger
- storage storage.Storage
- mastoConverter mastotypes.Converter
- mediaHandler media.Handler
- oauthServer oauth.Server
-
- // standard suite models
- testTokens map[string]*oauth.Token
- testClients map[string]*oauth.Client
- testApplications map[string]*gtsmodel.Application
- testUsers map[string]*gtsmodel.User
- testAccounts map[string]*gtsmodel.Account
- testAttachments map[string]*gtsmodel.MediaAttachment
-
- // item being tested
- fileServer *fileserver.FileServer
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-func (suite *ServeFileTestSuite) SetupSuite() {
- // setup standard items
- suite.config = testrig.NewTestConfig()
- suite.db = testrig.NewTestDB()
- suite.log = testrig.NewTestLog()
- suite.storage = testrig.NewTestStorage()
- suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
- suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
- suite.oauthServer = testrig.NewTestOauthServer(suite.db)
-
- // setup module being tested
- suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer)
-}
-
-func (suite *ServeFileTestSuite) TearDownSuite() {
- if err := suite.db.Stop(context.Background()); err != nil {
- logrus.Panicf("error closing db connection: %s", err)
- }
-}
-
-func (suite *ServeFileTestSuite) SetupTest() {
- testrig.StandardDBSetup(suite.db)
- testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
- suite.testTokens = testrig.NewTestTokens()
- suite.testClients = testrig.NewTestClients()
- suite.testApplications = testrig.NewTestApplications()
- suite.testUsers = testrig.NewTestUsers()
- suite.testAccounts = testrig.NewTestAccounts()
- suite.testAttachments = testrig.NewTestAttachments()
-}
-
-func (suite *ServeFileTestSuite) TearDownTest() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-/*
- ACTUAL TESTS
-*/
-
-func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() {
- targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"]
- assert.True(suite.T(), ok)
- assert.NotNil(suite.T(), targetAttachment)
-
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil)
-
- // normally the router would populate these params from the path values,
- // but because we're calling the ServeFile function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: fileserver.AccountIDKey,
- Value: targetAttachment.AccountID,
- },
- gin.Param{
- Key: fileserver.MediaTypeKey,
- Value: media.MediaAttachment,
- },
- gin.Param{
- Key: fileserver.MediaSizeKey,
- Value: media.MediaOriginal,
- },
- gin.Param{
- Key: fileserver.FileNameKey,
- Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID),
- },
- }
-
- // call the function we're testing and check status code
- suite.fileServer.ServeFile(ctx)
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- b, err := ioutil.ReadAll(recorder.Body)
- assert.NoError(suite.T(), err)
- assert.NotNil(suite.T(), b)
-
- fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path)
- assert.NoError(suite.T(), err)
- assert.NotNil(suite.T(), fileInStorage)
- assert.Equal(suite.T(), b, fileInStorage)
-}
-
-func TestServeFileTestSuite(t *testing.T) {
- suite.Run(t, new(ServeFileTestSuite))
-}
diff --git a/internal/apimodule/media/media.go b/internal/apimodule/media/media.go
@@ -1,76 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package media
-
-import (
- "fmt"
- "net/http"
-
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-// BasePath is the base API path for making media requests
-const BasePath = "/api/v1/media"
-
-// Module implements the ClientAPIModule interface for media
-type Module struct {
- mediaHandler media.Handler
- config *config.Config
- db db.DB
- mastoConverter mastotypes.Converter
- log *logrus.Logger
-}
-
-// New returns a new auth module
-func New(db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
- return &Module{
- mediaHandler: mediaHandler,
- config: config,
- db: db,
- mastoConverter: mastoConverter,
- log: log,
- }
-}
-
-// Route satisfies the RESTAPIModule interface
-func (m *Module) Route(s router.Router) error {
- s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler)
- return nil
-}
-
-// CreateTables populates necessary tables in the given DB
-func (m *Module) CreateTables(db db.DB) error {
- models := []interface{}{
- >smodel.MediaAttachment{},
- }
-
- for _, m := range models {
- if err := db.CreateTable(m); err != nil {
- return fmt.Errorf("error creating table: %s", err)
- }
- }
- return nil
-}
diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go
@@ -1,193 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package media
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "net/http"
- "strconv"
- "strings"
-
- "github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// MediaCreatePOSTHandler handles requests to create/upload media attachments
-func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
- l := m.log.WithField("func", "statusCreatePOSTHandler")
- authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything*
- if err != nil {
- l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
- return
- }
-
- // First check this user/account is permitted to create media
- // There's no point continuing otherwise.
- if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
- l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
- return
- }
-
- // extract the media create form from the request context
- l.Tracef("parsing request form: %s", c.Request.Form)
- form := &mastotypes.AttachmentRequest{}
- if err := c.ShouldBind(form); err != nil || form == nil {
- l.Debugf("could not parse form from request: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
- return
- }
-
- // Give the fields on the request form a first pass to make sure the request is superficially valid.
- l.Tracef("validating form %+v", form)
- if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
- l.Debugf("error validating form: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // open the attachment and extract the bytes from it
- f, err := form.File.Open()
- if err != nil {
- l.Debugf("error opening attachment: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)})
- return
- }
- buf := new(bytes.Buffer)
- size, err := io.Copy(buf, f)
- if err != nil {
- l.Debugf("error reading attachment: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)})
- return
- }
- if size == 0 {
- l.Debug("could not read provided attachment: size 0 bytes")
- c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"})
- return
- }
-
- // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using
- attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID)
- if err != nil {
- l.Debugf("error reading attachment: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)})
- return
- }
-
- // now we need to add extra fields that the attachment processor doesn't know (from the form)
- // TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it)
-
- // first description
- attachment.Description = form.Description
-
- // now parse the focus parameter
- // TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated
- var focusx, focusy float32
- if form.Focus != "" {
- spl := strings.Split(form.Focus, ",")
- if len(spl) != 2 {
- l.Debugf("improperly formatted focus %s", form.Focus)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
- return
- }
- xStr := spl[0]
- yStr := spl[1]
- if xStr == "" || yStr == "" {
- l.Debugf("improperly formatted focus %s", form.Focus)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
- return
- }
- fx, err := strconv.ParseFloat(xStr, 32)
- if err != nil {
- l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
- return
- }
- if fx > 1 || fx < -1 {
- l.Debugf("improperly formatted focus %s", form.Focus)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
- return
- }
- focusx = float32(fx)
- fy, err := strconv.ParseFloat(yStr, 32)
- if err != nil {
- l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
- return
- }
- if fy > 1 || fy < -1 {
- l.Debugf("improperly formatted focus %s", form.Focus)
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
- return
- }
- focusy = float32(fy)
- }
- attachment.FileMeta.Focus.X = focusx
- attachment.FileMeta.Focus.Y = focusy
-
- // prepare the frontend representation now -- if there are any errors here at least we can bail without
- // having already put something in the database and then having to clean it up again (eugh)
- mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment)
- if err != nil {
- l.Debugf("error parsing media attachment to frontend type: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)})
- return
- }
-
- // now we can confidently put the attachment in the database
- if err := m.db.Put(attachment); err != nil {
- l.Debugf("error storing media attachment in db: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)})
- return
- }
-
- // and return its frontend representation
- c.JSON(http.StatusAccepted, mastoAttachment)
-}
-
-func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error {
- // check there actually is a file attached and it's not size 0
- if form.File == nil || form.File.Size == 0 {
- return errors.New("no attachment given")
- }
-
- // a very superficial check to see if no size limits are exceeded
- // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
- maxSize := config.MaxVideoSize
- if config.MaxImageSize > maxSize {
- maxSize = config.MaxImageSize
- }
- if form.File.Size > int64(maxSize) {
- return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
- }
-
- if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars {
- return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description))
- }
-
- // TODO: validate focus here
-
- return nil
-}
diff --git a/internal/apimodule/media/test/mediacreate_test.go b/internal/apimodule/media/test/mediacreate_test.go
@@ -1,194 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package test
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io/ioutil"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/suite"
- mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/testrig"
-)
-
-type MediaCreateTestSuite struct {
- // standard suite interfaces
- suite.Suite
- config *config.Config
- db db.DB
- log *logrus.Logger
- storage storage.Storage
- mastoConverter mastotypes.Converter
- mediaHandler media.Handler
- oauthServer oauth.Server
-
- // standard suite models
- testTokens map[string]*oauth.Token
- testClients map[string]*oauth.Client
- testApplications map[string]*gtsmodel.Application
- testUsers map[string]*gtsmodel.User
- testAccounts map[string]*gtsmodel.Account
- testAttachments map[string]*gtsmodel.MediaAttachment
-
- // item being tested
- mediaModule *mediamodule.Module
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-func (suite *MediaCreateTestSuite) SetupSuite() {
- // setup standard items
- suite.config = testrig.NewTestConfig()
- suite.db = testrig.NewTestDB()
- suite.log = testrig.NewTestLog()
- suite.storage = testrig.NewTestStorage()
- suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
- suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
- suite.oauthServer = testrig.NewTestOauthServer(suite.db)
-
- // setup module being tested
- suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.Module)
-}
-
-func (suite *MediaCreateTestSuite) TearDownSuite() {
- if err := suite.db.Stop(context.Background()); err != nil {
- logrus.Panicf("error closing db connection: %s", err)
- }
-}
-
-func (suite *MediaCreateTestSuite) SetupTest() {
- testrig.StandardDBSetup(suite.db)
- testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
- suite.testTokens = testrig.NewTestTokens()
- suite.testClients = testrig.NewTestClients()
- suite.testApplications = testrig.NewTestApplications()
- suite.testUsers = testrig.NewTestUsers()
- suite.testAccounts = testrig.NewTestAccounts()
- suite.testAttachments = testrig.NewTestAttachments()
-}
-
-func (suite *MediaCreateTestSuite) TearDownTest() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-/*
- ACTUAL TESTS
-*/
-
-func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() {
-
- // set up the context for the request
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
-
- // see what's in storage *before* the request
- storageKeysBeforeRequest, err := suite.storage.ListKeys()
- if err != nil {
- panic(err)
- }
-
- // create the request
- buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
- "description": "this is a test image -- a cool background from somewhere",
- "focus": "-0.5,0.5",
- })
- if err != nil {
- panic(err)
- }
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting
- ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
-
- // do the actual request
- suite.mediaModule.MediaCreatePOSTHandler(ctx)
-
- // check what's in storage *after* the request
- storageKeysAfterRequest, err := suite.storage.ListKeys()
- if err != nil {
- panic(err)
- }
-
- // check response
- suite.EqualValues(http.StatusAccepted, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- fmt.Println(string(b))
-
- attachmentReply := &mastomodel.Attachment{}
- err = json.Unmarshal(b, attachmentReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description)
- assert.Equal(suite.T(), "image", attachmentReply.Type)
- assert.EqualValues(suite.T(), mastomodel.MediaMeta{
- Original: mastomodel.MediaDimensions{
- Width: 1920,
- Height: 1080,
- Size: "1920x1080",
- Aspect: 1.7777778,
- },
- Small: mastomodel.MediaDimensions{
- Width: 256,
- Height: 144,
- Size: "256x144",
- Aspect: 1.7777778,
- },
- Focus: mastomodel.MediaFocus{
- X: -0.5,
- Y: 0.5,
- },
- }, attachmentReply.Meta)
- assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash)
- assert.NotEmpty(suite.T(), attachmentReply.ID)
- assert.NotEmpty(suite.T(), attachmentReply.URL)
- assert.NotEmpty(suite.T(), attachmentReply.PreviewURL)
- assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail
-}
-
-func TestMediaCreateTestSuite(t *testing.T) {
- suite.Run(t, new(MediaCreateTestSuite))
-}
diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go
@@ -1,43 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package apimodule
-
-import (
- mock "github.com/stretchr/testify/mock"
- db "github.com/superseriousbusiness/gotosocial/internal/db"
-
- router "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-// MockClientAPIModule is an autogenerated mock type for the ClientAPIModule type
-type MockClientAPIModule struct {
- mock.Mock
-}
-
-// CreateTables provides a mock function with given fields: _a0
-func (_m *MockClientAPIModule) CreateTables(_a0 db.DB) error {
- ret := _m.Called(_a0)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(db.DB) error); ok {
- r0 = rf(_a0)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// Route provides a mock function with given fields: s
-func (_m *MockClientAPIModule) Route(s router.Router) error {
- ret := _m.Called(s)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(router.Router) error); ok {
- r0 = rf(s)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
diff --git a/internal/apimodule/security/security.go b/internal/apimodule/security/security.go
@@ -1,52 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package security
-
-import (
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-// Module implements the ClientAPIModule interface for security middleware
-type Module struct {
- config *config.Config
- log *logrus.Logger
-}
-
-// New returns a new security module
-func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
- return &Module{
- config: config,
- log: log,
- }
-}
-
-// Route attaches security middleware to the given router
-func (m *Module) Route(s router.Router) error {
- s.AttachMiddleware(m.FlocBlock)
- return nil
-}
-
-// CreateTables doesn't do diddly squat at the moment, it's just for fulfilling the interface
-func (m *Module) CreateTables(db db.DB) error {
- return nil
-}
diff --git a/internal/apimodule/status/status.go b/internal/apimodule/status/status.go
@@ -1,158 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "fmt"
- "net/http"
- "strings"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/router"
-)
-
-const (
- // IDKey is for status UUIDs
- IDKey = "id"
- // BasePath is the base path for serving the status API
- BasePath = "/api/v1/statuses"
- // BasePathWithID is just the base path with the ID key in it.
- // Use this anywhere you need to know the ID of the status being queried.
- BasePathWithID = BasePath + "/:" + IDKey
-
- // ContextPath is used for fetching context of posts
- ContextPath = BasePathWithID + "/context"
-
- // FavouritedPath is for seeing who's faved a given status
- FavouritedPath = BasePathWithID + "/favourited_by"
- // FavouritePath is for posting a fave on a status
- FavouritePath = BasePathWithID + "/favourite"
- // UnfavouritePath is for removing a fave from a status
- UnfavouritePath = BasePathWithID + "/unfavourite"
-
- // RebloggedPath is for seeing who's boosted a given status
- RebloggedPath = BasePathWithID + "/reblogged_by"
- // ReblogPath is for boosting/reblogging a given status
- ReblogPath = BasePathWithID + "/reblog"
- // UnreblogPath is for undoing a boost/reblog of a given status
- UnreblogPath = BasePathWithID + "/unreblog"
-
- // BookmarkPath is for creating a bookmark on a given status
- BookmarkPath = BasePathWithID + "/bookmark"
- // UnbookmarkPath is for removing a bookmark from a given status
- UnbookmarkPath = BasePathWithID + "/unbookmark"
-
- // MutePath is for muting a given status so that notifications will no longer be received about it.
- MutePath = BasePathWithID + "/mute"
- // UnmutePath is for undoing an existing mute
- UnmutePath = BasePathWithID + "/unmute"
-
- // PinPath is for pinning a status to an account profile so that it's the first thing people see
- PinPath = BasePathWithID + "/pin"
- // UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy
- UnpinPath = BasePathWithID + "/unpin"
-)
-
-// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses
-type Module struct {
- config *config.Config
- db db.DB
- mediaHandler media.Handler
- mastoConverter mastotypes.Converter
- distributor distributor.Distributor
- log *logrus.Logger
-}
-
-// New returns a new account module
-func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule {
- return &Module{
- config: config,
- db: db,
- mediaHandler: mediaHandler,
- mastoConverter: mastoConverter,
- distributor: distributor,
- log: log,
- }
-}
-
-// Route attaches all routes from this module to the given router
-func (m *Module) Route(r router.Router) error {
- r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
- r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
-
- r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
- r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler)
-
- r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
- return nil
-}
-
-// CreateTables populates necessary tables in the given DB
-func (m *Module) CreateTables(db db.DB) error {
- models := []interface{}{
- >smodel.User{},
- >smodel.Account{},
- >smodel.Block{},
- >smodel.Follow{},
- >smodel.FollowRequest{},
- >smodel.Status{},
- >smodel.StatusFave{},
- >smodel.StatusBookmark{},
- >smodel.StatusMute{},
- >smodel.StatusPin{},
- >smodel.Application{},
- >smodel.EmailDomainBlock{},
- >smodel.MediaAttachment{},
- >smodel.Emoji{},
- >smodel.Tag{},
- >smodel.Mention{},
- }
-
- for _, m := range models {
- if err := db.CreateTable(m); err != nil {
- return fmt.Errorf("error creating table: %s", err)
- }
- }
- return nil
-}
-
-// muxHandler is a little workaround to overcome the limitations of Gin
-func (m *Module) muxHandler(c *gin.Context) {
- m.log.Debug("entering mux handler")
- ru := c.Request.RequestURI
-
- switch c.Request.Method {
- case http.MethodGet:
- if strings.HasPrefix(ru, ContextPath) {
- // TODO
- } else if strings.HasPrefix(ru, FavouritedPath) {
- m.StatusFavedByGETHandler(c)
- } else {
- m.StatusGETHandler(c)
- }
- }
-}
diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go
@@ -1,462 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "errors"
- "fmt"
- "net/http"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/util"
-)
-
-type advancedStatusCreateForm struct {
- mastotypes.StatusCreateRequest
- advancedVisibilityFlagsForm
-}
-
-type advancedVisibilityFlagsForm struct {
- // The gotosocial visibility model
- VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"`
- // This status will be federated beyond the local timeline(s)
- Federated *bool `form:"federated"`
- // This status can be boosted/reblogged
- Boostable *bool `form:"boostable"`
- // This status can be replied to
- Replyable *bool `form:"replyable"`
- // This status can be liked/faved
- Likeable *bool `form:"likeable"`
-}
-
-// StatusCreatePOSTHandler deals with the creation of new statuses
-func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
- l := m.log.WithField("func", "statusCreatePOSTHandler")
- authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
- if err != nil {
- l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
- return
- }
-
- // First check this user/account is permitted to post new statuses.
- // There's no point continuing otherwise.
- if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
- l.Debugf("couldn't auth: %s", err)
- c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
- return
- }
-
- // extract the status create form from the request context
- l.Tracef("parsing request form: %s", c.Request.Form)
- form := &advancedStatusCreateForm{}
- if err := c.ShouldBind(form); err != nil || form == nil {
- l.Debugf("could not parse form from request: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
- return
- }
-
- // Give the fields on the request form a first pass to make sure the request is superficially valid.
- l.Tracef("validating form %+v", form)
- if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
- l.Debugf("error validating form: %s", err)
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // At this point we know the account is permitted to post, and we know the request form
- // is valid (at least according to the API specifications and the instance configuration).
- // So now we can start digging a bit deeper into the form and building up the new status from it.
-
- // first we create a new status and add some basic info to it
- uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host)
- thisStatusID := uuid.NewString()
- thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID)
- thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID)
- newStatus := >smodel.Status{
- ID: thisStatusID,
- URI: thisStatusURI,
- URL: thisStatusURL,
- Content: util.HTMLFormat(form.Status),
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- Local: true,
- AccountID: authed.Account.ID,
- ContentWarning: form.SpoilerText,
- ActivityStreamsType: gtsmodel.ActivityStreamsNote,
- Sensitive: form.Sensitive,
- Language: form.Language,
- CreatedWithApplicationID: authed.Application.ID,
- Text: form.Status,
- }
-
- // check if replyToID is ok
- if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // check if mediaIDs are ok
- if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // check if visibility settings are ok
- if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // handle language settings
- if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // handle mentions
- if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- /*
- FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it
- */
-
- // put the new status in the database, generating an ID for it in the process
- if err := m.db.Put(newStatus); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- // change the status ID of the media attachments to the new status
- for _, a := range newStatus.GTSMediaAttachments {
- a.StatusID = newStatus.ID
- a.UpdatedAt = time.Now()
- if err := m.db.UpdateByID(a.ID, a); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- }
-
- // pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc
- m.distributor.FromClientAPI() <- distributor.FromClientAPI{
- APObjectType: gtsmodel.ActivityStreamsNote,
- APActivityType: gtsmodel.ActivityStreamsCreate,
- Activity: newStatus,
- }
-
- // return the frontend representation of the new status to the submitter
- mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, mastoStatus)
-}
-
-func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error {
- // validate that, structurally, we have a valid status/post
- if form.Status == "" && form.MediaIDs == nil && form.Poll == nil {
- return errors.New("no status, media, or poll provided")
- }
-
- if form.MediaIDs != nil && form.Poll != nil {
- return errors.New("can't post media + poll in same status")
- }
-
- // validate status
- if form.Status != "" {
- if len(form.Status) > config.MaxChars {
- return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars)
- }
- }
-
- // validate media attachments
- if len(form.MediaIDs) > config.MaxMediaFiles {
- return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles)
- }
-
- // validate poll
- if form.Poll != nil {
- if form.Poll.Options == nil {
- return errors.New("poll with no options")
- }
- if len(form.Poll.Options) > config.PollMaxOptions {
- return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions)
- }
- for _, p := range form.Poll.Options {
- if len(p) > config.PollOptionMaxChars {
- return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars)
- }
- }
- }
-
- // validate spoiler text/cw
- if form.SpoilerText != "" {
- if len(form.SpoilerText) > config.CWMaxChars {
- return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars)
- }
- }
-
- // validate post language
- if form.Language != "" {
- if err := util.ValidateLanguage(form.Language); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error {
- // by default all flags are set to true
- gtsAdvancedVis := >smodel.VisibilityAdvanced{
- Federated: true,
- Boostable: true,
- Replyable: true,
- Likeable: true,
- }
-
- var gtsBasicVis gtsmodel.Visibility
- // Advanced takes priority if it's set.
- // If it's not set, take whatever masto visibility is set.
- // If *that's* not set either, then just take the account default.
- // If that's also not set, take the default for the whole instance.
- if form.VisibilityAdvanced != nil {
- gtsBasicVis = *form.VisibilityAdvanced
- } else if form.Visibility != "" {
- gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility)
- } else if accountDefaultVis != "" {
- gtsBasicVis = accountDefaultVis
- } else {
- gtsBasicVis = gtsmodel.VisibilityDefault
- }
-
- switch gtsBasicVis {
- case gtsmodel.VisibilityPublic:
- // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
- break
- case gtsmodel.VisibilityUnlocked:
- // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
- if form.Federated != nil {
- gtsAdvancedVis.Federated = *form.Federated
- }
-
- if form.Boostable != nil {
- gtsAdvancedVis.Boostable = *form.Boostable
- }
-
- if form.Replyable != nil {
- gtsAdvancedVis.Replyable = *form.Replyable
- }
-
- if form.Likeable != nil {
- gtsAdvancedVis.Likeable = *form.Likeable
- }
-
- case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
- // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
- gtsAdvancedVis.Boostable = false
-
- if form.Federated != nil {
- gtsAdvancedVis.Federated = *form.Federated
- }
-
- if form.Replyable != nil {
- gtsAdvancedVis.Replyable = *form.Replyable
- }
-
- if form.Likeable != nil {
- gtsAdvancedVis.Likeable = *form.Likeable
- }
-
- case gtsmodel.VisibilityDirect:
- // direct is pretty easy: there's only one possible setting so return it
- gtsAdvancedVis.Federated = true
- gtsAdvancedVis.Boostable = false
- gtsAdvancedVis.Federated = true
- gtsAdvancedVis.Likeable = true
- }
-
- status.Visibility = gtsBasicVis
- status.VisibilityAdvanced = gtsAdvancedVis
- return nil
-}
-
-func (m *Module) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
- if form.InReplyToID == "" {
- return nil
- }
-
- // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
- //
- // 1. Does the replied status exist in the database?
- // 2. Is the replied status marked as replyable?
- // 3. Does a block exist between either the current account or the account that posted the status it's replying to?
- //
- // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
- repliedStatus := >smodel.Status{}
- repliedAccount := >smodel.Account{}
- // check replied status exists + is replyable
- if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil {
- if _, ok := err.(db.ErrNoEntries); ok {
- return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID)
- }
- return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
- }
-
- if !repliedStatus.VisibilityAdvanced.Replyable {
- return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID)
- }
-
- // check replied account is known to us
- if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil {
- if _, ok := err.(db.ErrNoEntries); ok {
- return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID)
- }
- return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
- }
- // check if a block exists
- if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil {
- if _, ok := err.(db.ErrNoEntries); !ok {
- return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
- }
- } else if blocked {
- return fmt.Errorf("status with id %s not replyable", form.InReplyToID)
- }
- status.InReplyToID = repliedStatus.ID
- status.InReplyToAccountID = repliedAccount.ID
-
- return nil
-}
-
-func (m *Module) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
- if form.MediaIDs == nil {
- return nil
- }
-
- gtsMediaAttachments := []*gtsmodel.MediaAttachment{}
- attachments := []string{}
- for _, mediaID := range form.MediaIDs {
- // check these attachments exist
- a := >smodel.MediaAttachment{}
- if err := m.db.GetByID(mediaID, a); err != nil {
- return fmt.Errorf("invalid media type or media not found for media id %s", mediaID)
- }
- // check they belong to the requesting account id
- if a.AccountID != thisAccountID {
- return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID)
- }
- // check they're not already used in a status
- if a.StatusID != "" || a.ScheduledStatusID != "" {
- return fmt.Errorf("media with id %s is already attached to a status", mediaID)
- }
- gtsMediaAttachments = append(gtsMediaAttachments, a)
- attachments = append(attachments, a.ID)
- }
- status.GTSMediaAttachments = gtsMediaAttachments
- status.Attachments = attachments
- return nil
-}
-
-func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error {
- if form.Language != "" {
- status.Language = form.Language
- } else {
- status.Language = accountDefaultLanguage
- }
- if status.Language == "" {
- return errors.New("no language given either in status create form or account default")
- }
- return nil
-}
-
-func (m *Module) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
- menchies := []string{}
- gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID)
- if err != nil {
- return fmt.Errorf("error generating mentions from status: %s", err)
- }
- for _, menchie := range gtsMenchies {
- if err := m.db.Put(menchie); err != nil {
- return fmt.Errorf("error putting mentions in db: %s", err)
- }
- menchies = append(menchies, menchie.TargetAccountID)
- }
- // add full populated gts menchies to the status for passing them around conveniently
- status.GTSMentions = gtsMenchies
- // add just the ids of the mentioned accounts to the status for putting in the db
- status.Mentions = menchies
- return nil
-}
-
-func (m *Module) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
- tags := []string{}
- gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID)
- if err != nil {
- return fmt.Errorf("error generating hashtags from status: %s", err)
- }
- for _, tag := range gtsTags {
- if err := m.db.Upsert(tag, "name"); err != nil {
- return fmt.Errorf("error putting tags in db: %s", err)
- }
- tags = append(tags, tag.ID)
- }
- // add full populated gts tags to the status for passing them around conveniently
- status.GTSTags = gtsTags
- // add just the ids of the used tags to the status for putting in the db
- status.Tags = tags
- return nil
-}
-
-func (m *Module) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
- emojis := []string{}
- gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID)
- if err != nil {
- return fmt.Errorf("error generating emojis from status: %s", err)
- }
- for _, e := range gtsEmojis {
- emojis = append(emojis, e.ID)
- }
- // add full populated gts emojis to the status for passing them around conveniently
- status.GTSEmojis = gtsEmojis
- // add just the ids of the used emojis to the status for putting in the db
- status.Emojis = emojis
- return nil
-}
diff --git a/internal/apimodule/status/statusdelete.go b/internal/apimodule/status/statusdelete.go
@@ -1,107 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "fmt"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusDELETEHandler verifies and handles deletion of a status
-func (m *Module) StatusDELETEHandler(c *gin.Context) {
- l := m.log.WithFields(logrus.Fields{
- "func": "StatusDELETEHandler",
- "request_uri": c.Request.RequestURI,
- "user_agent": c.Request.UserAgent(),
- "origin_ip": c.ClientIP(),
- })
- l.Debugf("entering function")
-
- authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
- if err != nil {
- l.Debug("not authed so can't delete status")
- c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
- return
- }
-
- targetStatusID := c.Param(IDKey)
- if targetStatusID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
- return
- }
-
- l.Tracef("going to search for target status %s", targetStatusID)
- targetStatus := >smodel.Status{}
- if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
- l.Errorf("error fetching status %s: %s", targetStatusID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- if targetStatus.AccountID != authed.Account.ID {
- l.Debug("status doesn't belong to requesting account")
- c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"})
- return
- }
-
- l.Trace("going to get relevant accounts")
- relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
- if err != nil {
- l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- var boostOfStatus *gtsmodel.Status
- if targetStatus.BoostOfID != "" {
- boostOfStatus = >smodel.Status{}
- if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
- l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
- }
-
- mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
- if err != nil {
- l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil {
- l.Errorf("error deleting status from the database: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- m.distributor.FromClientAPI() <- distributor.FromClientAPI{
- APObjectType: gtsmodel.ActivityStreamsNote,
- APActivityType: gtsmodel.ActivityStreamsDelete,
- Activity: targetStatus,
- }
-
- c.JSON(http.StatusOK, mastoStatus)
-}
diff --git a/internal/apimodule/status/statusfave.go b/internal/apimodule/status/statusfave.go
@@ -1,137 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "fmt"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusFavePOSTHandler handles fave requests against a given status ID
-func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
- l := m.log.WithFields(logrus.Fields{
- "func": "StatusFavePOSTHandler",
- "request_uri": c.Request.RequestURI,
- "user_agent": c.Request.UserAgent(),
- "origin_ip": c.ClientIP(),
- })
- l.Debugf("entering function")
-
- authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
- if err != nil {
- l.Debug("not authed so can't fave status")
- c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
- return
- }
-
- targetStatusID := c.Param(IDKey)
- if targetStatusID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
- return
- }
-
- l.Tracef("going to search for target status %s", targetStatusID)
- targetStatus := >smodel.Status{}
- if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
- l.Errorf("error fetching status %s: %s", targetStatusID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Tracef("going to search for target account %s", targetStatus.AccountID)
- targetAccount := >smodel.Account{}
- if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
- l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Trace("going to get relevant accounts")
- relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
- if err != nil {
- l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Trace("going to see if status is visible")
- visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
- if err != nil {
- l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- if !visible {
- l.Trace("status is not visible")
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- // is the status faveable?
- if !targetStatus.VisibilityAdvanced.Likeable {
- l.Debug("status is not faveable")
- c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)})
- return
- }
-
- // it's visible! it's faveable! so let's fave the FUCK out of it
- fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID)
- if err != nil {
- l.Debugf("error faveing status: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- var boostOfStatus *gtsmodel.Status
- if targetStatus.BoostOfID != "" {
- boostOfStatus = >smodel.Status{}
- if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
- l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
- }
-
- mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
- if err != nil {
- l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- // if the targeted status was already faved, faved will be nil
- // only put the fave in the distributor if something actually changed
- if fave != nil {
- fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
- m.distributor.FromClientAPI() <- distributor.FromClientAPI{
- APObjectType: gtsmodel.ActivityStreamsNote, // status is a note
- APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note
- Activity: fave, // pass the fave along for processing
- }
- }
-
- c.JSON(http.StatusOK, mastoStatus)
-}
diff --git a/internal/apimodule/status/statusfavedby.go b/internal/apimodule/status/statusfavedby.go
@@ -1,129 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "fmt"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status
-func (m *Module) StatusFavedByGETHandler(c *gin.Context) {
- l := m.log.WithFields(logrus.Fields{
- "func": "statusGETHandler",
- "request_uri": c.Request.RequestURI,
- "user_agent": c.Request.UserAgent(),
- "origin_ip": c.ClientIP(),
- })
- l.Debugf("entering function")
-
- var requestingAccount *gtsmodel.Account
- authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
- if err != nil {
- l.Debug("not authed but will continue to serve anyway if public status")
- requestingAccount = nil
- } else {
- requestingAccount = authed.Account
- }
-
- targetStatusID := c.Param(IDKey)
- if targetStatusID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
- return
- }
-
- l.Tracef("going to search for target status %s", targetStatusID)
- targetStatus := >smodel.Status{}
- if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
- l.Errorf("error fetching status %s: %s", targetStatusID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Tracef("going to search for target account %s", targetStatus.AccountID)
- targetAccount := >smodel.Account{}
- if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
- l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Trace("going to get relevant accounts")
- relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
- if err != nil {
- l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Trace("going to see if status is visible")
- visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
- if err != nil {
- l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- if !visible {
- l.Trace("status is not visible")
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
- favingAccounts, err := m.db.WhoFavedStatus(targetStatus)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- // filter the list so the user doesn't see accounts they blocked or which blocked them
- filteredAccounts := []*gtsmodel.Account{}
- for _, acc := range favingAccounts {
- blocked, err := m.db.Blocked(authed.Account.ID, acc.ID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if !blocked {
- filteredAccounts = append(filteredAccounts, acc)
- }
- }
-
- // TODO: filter other things here? suspended? muted? silenced?
-
- // now we can return the masto representation of those accounts
- mastoAccounts := []*mastotypes.Account{}
- for _, acc := range filteredAccounts {
- mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- mastoAccounts = append(mastoAccounts, mastoAccount)
- }
-
- c.JSON(http.StatusOK, mastoAccounts)
-}
diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go
@@ -1,112 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "fmt"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusGETHandler is for handling requests to just get one status based on its ID
-func (m *Module) 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/statusunfave.go b/internal/apimodule/status/statusunfave.go
@@ -1,137 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "fmt"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
-)
-
-// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID
-func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) {
- l := m.log.WithFields(logrus.Fields{
- "func": "StatusUnfavePOSTHandler",
- "request_uri": c.Request.RequestURI,
- "user_agent": c.Request.UserAgent(),
- "origin_ip": c.ClientIP(),
- })
- l.Debugf("entering function")
-
- authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
- if err != nil {
- l.Debug("not authed so can't unfave status")
- c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
- return
- }
-
- targetStatusID := c.Param(IDKey)
- if targetStatusID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
- return
- }
-
- l.Tracef("going to search for target status %s", targetStatusID)
- targetStatus := >smodel.Status{}
- if err := m.db.GetByID(targetStatusID, targetStatus); err != nil {
- l.Errorf("error fetching status %s: %s", targetStatusID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Tracef("going to search for target account %s", targetStatus.AccountID)
- targetAccount := >smodel.Account{}
- if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
- l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Trace("going to get relevant accounts")
- relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus)
- if err != nil {
- l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- l.Trace("going to see if status is visible")
- visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
- if err != nil {
- l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- if !visible {
- l.Trace("status is not visible")
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- // is the status faveable?
- if !targetStatus.VisibilityAdvanced.Likeable {
- l.Debug("status is not faveable")
- c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)})
- return
- }
-
- // it's visible! it's faveable! so let's unfave the FUCK out of it
- fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID)
- if err != nil {
- l.Debugf("error unfaveing status: %s", err)
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- var boostOfStatus *gtsmodel.Status
- if targetStatus.BoostOfID != "" {
- boostOfStatus = >smodel.Status{}
- if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
- l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
- }
-
- mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
- if err != nil {
- l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
- c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)})
- return
- }
-
- // fave might be nil if this status wasn't faved in the first place
- // we only want to pass the message to the distributor if something actually changed
- if fave != nil {
- fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor
- m.distributor.FromClientAPI() <- distributor.FromClientAPI{
- APObjectType: gtsmodel.ActivityStreamsNote, // status is a note
- APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave
- Activity: fave, // pass the undone fave along
- }
- }
-
- c.JSON(http.StatusOK, mastoStatus)
-}
diff --git a/internal/apimodule/status/test/statuscreate_test.go b/internal/apimodule/status/test/statuscreate_test.go
@@ -1,346 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "encoding/json"
- "fmt"
- "io/ioutil"
- "net/http"
- "net/http/httptest"
- "net/url"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/testrig"
-)
-
-type StatusCreateTestSuite struct {
- // standard suite interfaces
- suite.Suite
- config *config.Config
- db db.DB
- log *logrus.Logger
- storage storage.Storage
- mastoConverter mastotypes.Converter
- mediaHandler media.Handler
- oauthServer oauth.Server
- distributor distributor.Distributor
-
- // standard suite models
- testTokens map[string]*oauth.Token
- testClients map[string]*oauth.Client
- testApplications map[string]*gtsmodel.Application
- testUsers map[string]*gtsmodel.User
- testAccounts map[string]*gtsmodel.Account
- testAttachments map[string]*gtsmodel.MediaAttachment
-
- // module being tested
- statusModule *status.Module
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *StatusCreateTestSuite) SetupSuite() {
- // setup standard items
- suite.config = testrig.NewTestConfig()
- suite.db = testrig.NewTestDB()
- suite.log = testrig.NewTestLog()
- suite.storage = testrig.NewTestStorage()
- suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
- suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
- suite.oauthServer = testrig.NewTestOauthServer(suite.db)
- suite.distributor = testrig.NewTestDistributor()
-
- // setup module being tested
- suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusCreateTestSuite) TearDownSuite() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusCreateTestSuite) SetupTest() {
- testrig.StandardDBSetup(suite.db)
- testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
- suite.testTokens = testrig.NewTestTokens()
- suite.testClients = testrig.NewTestClients()
- suite.testApplications = testrig.NewTestApplications()
- suite.testUsers = testrig.NewTestUsers()
- suite.testAccounts = testrig.NewTestAccounts()
- suite.testAttachments = testrig.NewTestAttachments()
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *StatusCreateTestSuite) TearDownTest() {
- testrig.StandardDBTeardown(suite.db)
-}
-
-/*
- ACTUAL TESTS
-*/
-
-/*
- TESTING: StatusCreatePOSTHandler
-*/
-
-// Post a new status with some custom visibility settings
-func (suite *StatusCreateTestSuite) TestPostNewStatus() {
-
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = url.Values{
- "status": {"this is a brand new status! #helloworld"},
- "spoiler_text": {"hello hello"},
- "sensitive": {"true"},
- "visibility_advanced": {"mutuals_only"},
- "likeable": {"false"},
- "replyable": {"false"},
- "federated": {"false"},
- }
- suite.statusModule.StatusCreatePOSTHandler(ctx)
-
- // check response
-
- // 1. we should have OK from our call to the function
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- statusReply := &mastomodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
- assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
- assert.True(suite.T(), statusReply.Sensitive)
- assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
- assert.Len(suite.T(), statusReply.Tags, 1)
- assert.Equal(suite.T(), mastomodel.Tag{
- Name: "helloworld",
- URL: "http://localhost:8080/tags/helloworld",
- }, statusReply.Tags[0])
-
- gtsTag := >smodel.Tag{}
- err = suite.db.GetWhere("name", "helloworld", gtsTag)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID)
-}
-
-func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() {
-
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = url.Values{
- "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "},
- }
- suite.statusModule.StatusCreatePOSTHandler(ctx)
-
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- statusReply := &mastomodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), "", statusReply.SpoilerText)
- assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content)
-
- assert.Len(suite.T(), statusReply.Emojis, 1)
- mastoEmoji := statusReply.Emojis[0]
- gtsEmoji := testrig.NewTestEmojis()["rainbow"]
-
- assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode)
- assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL)
- assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL)
-}
-
-// Try to reply to a status that doesn't exist
-func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = url.Values{
- "status": {"this is a reply to a status that doesn't exist"},
- "spoiler_text": {"don't open cuz it won't work"},
- "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"},
- }
- suite.statusModule.StatusCreatePOSTHandler(ctx)
-
- // check response
-
- suite.EqualValues(http.StatusBadRequest, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b))
-}
-
-// Post a reply to the status of a local user that allows replies.
-func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = url.Values{
- "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)},
- "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID},
- }
- suite.statusModule.StatusCreatePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- statusReply := &mastomodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), "", statusReply.SpoilerText)
- assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content)
- assert.False(suite.T(), statusReply.Sensitive)
- assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
- assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID)
- assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID)
- assert.Len(suite.T(), statusReply.Mentions, 1)
-}
-
-// Take a media file which is currently not associated with a status, and attach it to a new status.
-func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
- ctx.Request.Form = url.Values{
- "status": {"here's an image attachment"},
- "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"},
- }
- suite.statusModule.StatusCreatePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- fmt.Println(string(b))
-
- statusReply := &mastomodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), "", statusReply.SpoilerText)
- assert.Equal(suite.T(), "here's an image attachment", statusReply.Content)
- assert.False(suite.T(), statusReply.Sensitive)
- assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
-
- // there should be one media attachment
- assert.Len(suite.T(), statusReply.MediaAttachments, 1)
-
- // get the updated media attachment from the database
- gtsAttachment := >smodel.MediaAttachment{}
- err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment)
- assert.NoError(suite.T(), err)
-
- // convert it to a masto attachment
- gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment)
- assert.NoError(suite.T(), err)
-
- // compare it with what we have now
- assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto)
-
- // the status id of the attachment should now be set to the id of the status we just created
- assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID)
-}
-
-func TestStatusCreateTestSuite(t *testing.T) {
- suite.Run(t, new(StatusCreateTestSuite))
-}
diff --git a/internal/apimodule/status/test/statusfave_test.go b/internal/apimodule/status/test/statusfave_test.go
@@ -1,207 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "encoding/json"
- "fmt"
- "io/ioutil"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/testrig"
-)
-
-type StatusFaveTestSuite struct {
- // standard suite interfaces
- suite.Suite
- config *config.Config
- db db.DB
- log *logrus.Logger
- storage storage.Storage
- mastoConverter mastotypes.Converter
- mediaHandler media.Handler
- oauthServer oauth.Server
- distributor distributor.Distributor
-
- // standard suite models
- testTokens map[string]*oauth.Token
- testClients map[string]*oauth.Client
- testApplications map[string]*gtsmodel.Application
- testUsers map[string]*gtsmodel.User
- testAccounts map[string]*gtsmodel.Account
- testAttachments map[string]*gtsmodel.MediaAttachment
- testStatuses map[string]*gtsmodel.Status
-
- // module being tested
- statusModule *status.Module
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *StatusFaveTestSuite) SetupSuite() {
- // setup standard items
- suite.config = testrig.NewTestConfig()
- suite.db = testrig.NewTestDB()
- suite.log = testrig.NewTestLog()
- suite.storage = testrig.NewTestStorage()
- suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
- suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
- suite.oauthServer = testrig.NewTestOauthServer(suite.db)
- suite.distributor = testrig.NewTestDistributor()
-
- // setup module being tested
- suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusFaveTestSuite) TearDownSuite() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusFaveTestSuite) SetupTest() {
- testrig.StandardDBSetup(suite.db)
- testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
- suite.testTokens = testrig.NewTestTokens()
- suite.testClients = testrig.NewTestClients()
- suite.testApplications = testrig.NewTestApplications()
- suite.testUsers = testrig.NewTestUsers()
- suite.testAccounts = testrig.NewTestAccounts()
- suite.testAttachments = testrig.NewTestAttachments()
- suite.testStatuses = testrig.NewTestStatuses()
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *StatusFaveTestSuite) TearDownTest() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-/*
- ACTUAL TESTS
-*/
-
-// fave a status
-func (suite *StatusFaveTestSuite) TestPostFave() {
-
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- targetStatus := suite.testStatuses["admin_account_status_2"]
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: status.IDKey,
- Value: targetStatus.ID,
- },
- }
-
- suite.statusModule.StatusFavePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- statusReply := &mastomodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
- assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
- assert.True(suite.T(), statusReply.Sensitive)
- assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
- assert.True(suite.T(), statusReply.Favourited)
- assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
-}
-
-// try to fave a status that's not faveable
-func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
-
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: status.IDKey,
- Value: targetStatus.ID,
- },
- }
-
- suite.statusModule.StatusFavePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b))
-}
-
-func TestStatusFaveTestSuite(t *testing.T) {
- suite.Run(t, new(StatusFaveTestSuite))
-}
diff --git a/internal/apimodule/status/test/statusfavedby_test.go b/internal/apimodule/status/test/statusfavedby_test.go
@@ -1,159 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "encoding/json"
- "fmt"
- "io/ioutil"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/testrig"
-)
-
-type StatusFavedByTestSuite struct {
- // standard suite interfaces
- suite.Suite
- config *config.Config
- db db.DB
- log *logrus.Logger
- storage storage.Storage
- mastoConverter mastotypes.Converter
- mediaHandler media.Handler
- oauthServer oauth.Server
- distributor distributor.Distributor
-
- // standard suite models
- testTokens map[string]*oauth.Token
- testClients map[string]*oauth.Client
- testApplications map[string]*gtsmodel.Application
- testUsers map[string]*gtsmodel.User
- testAccounts map[string]*gtsmodel.Account
- testAttachments map[string]*gtsmodel.MediaAttachment
- testStatuses map[string]*gtsmodel.Status
-
- // module being tested
- statusModule *status.Module
-}
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *StatusFavedByTestSuite) SetupSuite() {
- // setup standard items
- suite.config = testrig.NewTestConfig()
- suite.db = testrig.NewTestDB()
- suite.log = testrig.NewTestLog()
- suite.storage = testrig.NewTestStorage()
- suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
- suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
- suite.oauthServer = testrig.NewTestOauthServer(suite.db)
- suite.distributor = testrig.NewTestDistributor()
-
- // setup module being tested
- suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusFavedByTestSuite) TearDownSuite() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusFavedByTestSuite) SetupTest() {
- testrig.StandardDBSetup(suite.db)
- testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
- suite.testTokens = testrig.NewTestTokens()
- suite.testClients = testrig.NewTestClients()
- suite.testApplications = testrig.NewTestApplications()
- suite.testUsers = testrig.NewTestUsers()
- suite.testAccounts = testrig.NewTestAccounts()
- suite.testAttachments = testrig.NewTestAttachments()
- suite.testStatuses = testrig.NewTestStatuses()
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *StatusFavedByTestSuite) TearDownTest() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-/*
- ACTUAL TESTS
-*/
-
-func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
- t := suite.testTokens["local_account_2"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: status.IDKey,
- Value: targetStatus.ID,
- },
- }
-
- suite.statusModule.StatusFavedByGETHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- accts := []mastomodel.Account{}
- err = json.Unmarshal(b, &accts)
- assert.NoError(suite.T(), err)
-
- assert.Len(suite.T(), accts, 1)
- assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username)
-}
-
-func TestStatusFavedByTestSuite(t *testing.T) {
- suite.Run(t, new(StatusFavedByTestSuite))
-}
diff --git a/internal/apimodule/status/test/statusget_test.go b/internal/apimodule/status/test/statusget_test.go
@@ -1,168 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "testing"
-
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/testrig"
-)
-
-type StatusGetTestSuite struct {
- // standard suite interfaces
- suite.Suite
- config *config.Config
- db db.DB
- log *logrus.Logger
- storage storage.Storage
- mastoConverter mastotypes.Converter
- mediaHandler media.Handler
- oauthServer oauth.Server
- distributor distributor.Distributor
-
- // standard suite models
- testTokens map[string]*oauth.Token
- testClients map[string]*oauth.Client
- testApplications map[string]*gtsmodel.Application
- testUsers map[string]*gtsmodel.User
- testAccounts map[string]*gtsmodel.Account
- testAttachments map[string]*gtsmodel.MediaAttachment
-
- // module being tested
- statusModule *status.Module
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *StatusGetTestSuite) SetupSuite() {
- // setup standard items
- suite.config = testrig.NewTestConfig()
- suite.db = testrig.NewTestDB()
- suite.log = testrig.NewTestLog()
- suite.storage = testrig.NewTestStorage()
- suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
- suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
- suite.oauthServer = testrig.NewTestOauthServer(suite.db)
- suite.distributor = testrig.NewTestDistributor()
-
- // setup module being tested
- suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-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/apimodule/status/test/statusunfave_test.go b/internal/apimodule/status/test/statusunfave_test.go
@@ -1,219 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package status
-
-import (
- "encoding/json"
- "fmt"
- "io/ioutil"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/suite"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
- mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/media"
- "github.com/superseriousbusiness/gotosocial/internal/oauth"
- "github.com/superseriousbusiness/gotosocial/internal/storage"
- "github.com/superseriousbusiness/gotosocial/testrig"
-)
-
-type StatusUnfaveTestSuite struct {
- // standard suite interfaces
- suite.Suite
- config *config.Config
- db db.DB
- log *logrus.Logger
- storage storage.Storage
- mastoConverter mastotypes.Converter
- mediaHandler media.Handler
- oauthServer oauth.Server
- distributor distributor.Distributor
-
- // standard suite models
- testTokens map[string]*oauth.Token
- testClients map[string]*oauth.Client
- testApplications map[string]*gtsmodel.Application
- testUsers map[string]*gtsmodel.User
- testAccounts map[string]*gtsmodel.Account
- testAttachments map[string]*gtsmodel.MediaAttachment
- testStatuses map[string]*gtsmodel.Status
-
- // module being tested
- statusModule *status.Module
-}
-
-/*
- TEST INFRASTRUCTURE
-*/
-
-// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
-func (suite *StatusUnfaveTestSuite) SetupSuite() {
- // setup standard items
- suite.config = testrig.NewTestConfig()
- suite.db = testrig.NewTestDB()
- suite.log = testrig.NewTestLog()
- suite.storage = testrig.NewTestStorage()
- suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
- suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
- suite.oauthServer = testrig.NewTestOauthServer(suite.db)
- suite.distributor = testrig.NewTestDistributor()
-
- // setup module being tested
- suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module)
-}
-
-func (suite *StatusUnfaveTestSuite) TearDownSuite() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-func (suite *StatusUnfaveTestSuite) SetupTest() {
- testrig.StandardDBSetup(suite.db)
- testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
- suite.testTokens = testrig.NewTestTokens()
- suite.testClients = testrig.NewTestClients()
- suite.testApplications = testrig.NewTestApplications()
- suite.testUsers = testrig.NewTestUsers()
- suite.testAccounts = testrig.NewTestAccounts()
- suite.testAttachments = testrig.NewTestAttachments()
- suite.testStatuses = testrig.NewTestStatuses()
-}
-
-// TearDownTest drops tables to make sure there's no data in the db
-func (suite *StatusUnfaveTestSuite) TearDownTest() {
- testrig.StandardDBTeardown(suite.db)
- testrig.StandardStorageTeardown(suite.storage)
-}
-
-/*
- ACTUAL TESTS
-*/
-
-// unfave a status
-func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
-
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- // this is the status we wanna unfave: in the testrig it's already faved by this account
- targetStatus := suite.testStatuses["admin_account_status_1"]
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: status.IDKey,
- Value: targetStatus.ID,
- },
- }
-
- suite.statusModule.StatusUnfavePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- statusReply := &mastomodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
- assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
- assert.False(suite.T(), statusReply.Sensitive)
- assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
- assert.False(suite.T(), statusReply.Favourited)
- assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
-}
-
-// try to unfave a status that's already not faved
-func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {
-
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.TokenToOauthToken(t)
-
- // this is the status we wanna unfave: in the testrig it's not faved by this account
- targetStatus := suite.testStatuses["admin_account_status_2"]
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: status.IDKey,
- Value: targetStatus.ID,
- },
- }
-
- suite.statusModule.StatusUnfavePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- statusReply := &mastomodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
- assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
- assert.True(suite.T(), statusReply.Sensitive)
- assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
- assert.False(suite.T(), statusReply.Favourited)
- assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
-}
-
-func TestStatusUnfaveTestSuite(t *testing.T) {
- suite.Run(t, new(StatusUnfaveTestSuite))
-}
diff --git a/internal/cache/mock_Cache.go b/internal/cache/mock_Cache.go
@@ -1,47 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package cache
-
-import mock "github.com/stretchr/testify/mock"
-
-// MockCache is an autogenerated mock type for the Cache type
-type MockCache struct {
- mock.Mock
-}
-
-// Fetch provides a mock function with given fields: k
-func (_m *MockCache) Fetch(k string) (interface{}, error) {
- ret := _m.Called(k)
-
- var r0 interface{}
- if rf, ok := ret.Get(0).(func(string) interface{}); ok {
- r0 = rf(k)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(interface{})
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(string) error); ok {
- r1 = rf(k)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// Store provides a mock function with given fields: k, v
-func (_m *MockCache) Store(k string, v interface{}) error {
- ret := _m.Called(k, v)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
- r0 = rf(k, v)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
diff --git a/internal/config/mock_KeyedFlags.go b/internal/config/mock_KeyedFlags.go
@@ -1,66 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package config
-
-import mock "github.com/stretchr/testify/mock"
-
-// MockKeyedFlags is an autogenerated mock type for the KeyedFlags type
-type MockKeyedFlags struct {
- mock.Mock
-}
-
-// Bool provides a mock function with given fields: k
-func (_m *MockKeyedFlags) Bool(k string) bool {
- ret := _m.Called(k)
-
- var r0 bool
- if rf, ok := ret.Get(0).(func(string) bool); ok {
- r0 = rf(k)
- } else {
- r0 = ret.Get(0).(bool)
- }
-
- return r0
-}
-
-// Int provides a mock function with given fields: k
-func (_m *MockKeyedFlags) Int(k string) int {
- ret := _m.Called(k)
-
- var r0 int
- if rf, ok := ret.Get(0).(func(string) int); ok {
- r0 = rf(k)
- } else {
- r0 = ret.Get(0).(int)
- }
-
- return r0
-}
-
-// IsSet provides a mock function with given fields: k
-func (_m *MockKeyedFlags) IsSet(k string) bool {
- ret := _m.Called(k)
-
- var r0 bool
- if rf, ok := ret.Get(0).(func(string) bool); ok {
- r0 = rf(k)
- } else {
- r0 = ret.Get(0).(bool)
- }
-
- return r0
-}
-
-// String provides a mock function with given fields: k
-func (_m *MockKeyedFlags) String(k string) string {
- ret := _m.Called(k)
-
- var r0 string
- if rf, ok := ret.Get(0).(func(string) string); ok {
- r0 = rf(k)
- } else {
- r0 = ret.Get(0).(string)
- }
-
- return r0
-}
diff --git a/internal/db/db.go b/internal/db/db.go
@@ -20,17 +20,13 @@ package db
import (
"context"
- "fmt"
"net"
- "strings"
"github.com/go-fed/activity/pub"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
-const dbTypePostgres string = "POSTGRES"
+const DBTypePostgres string = "POSTGRES"
// ErrNoEntries is to be returned from the DB interface when no entries are found for a given query.
type ErrNoEntries struct{}
@@ -126,6 +122,12 @@ type DB interface {
// In case of no entries, a 'no entries' error will be returned
GetAccountByUserID(userID string, account *gtsmodel.Account) error
+ // GetLocalAccountByUsername is a shortcut for the common action of fetching an account ON THIS INSTANCE
+ // according to its username, which should be unique.
+ // The given account pointer will be set to the result of the query, whatever it is.
+ // In case of no entries, a 'no entries' error will be returned
+ GetLocalAccountByUsername(username string, account *gtsmodel.Account) error
+
// GetFollowRequestsForAccountID is a shortcut for the common action of fetching a list of follow requests targeting the given account ID.
// The given slice 'followRequests' will be set to the result of the query, whatever it is.
// In case of no entries, a 'no entries' error will be returned
@@ -277,14 +279,3 @@ type DB interface {
// if they exist in the db and conveniently returning them if they do.
EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error)
}
-
-// New returns a new database service that satisfies the DB interface and, by extension,
-// the go-fed database interface described here: https://github.com/go-fed/activity/blob/master/pub/database.go
-func New(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) {
- switch strings.ToUpper(c.DBConfig.Type) {
- case dbTypePostgres:
- return newPostgresService(ctx, c, log.WithField("service", "db"))
- default:
- return nil, fmt.Errorf("database type %s not supported", c.DBConfig.Type)
- }
-}
diff --git a/internal/db/federating_db.go b/internal/db/federating_db.go
@@ -21,12 +21,16 @@ package db
import (
"context"
"errors"
+ "fmt"
"net/url"
"sync"
"github.com/go-fed/activity/pub"
"github.com/go-fed/activity/streams/vocab"
+ "github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
// FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface.
@@ -35,13 +39,15 @@ type federatingDB struct {
locks *sync.Map
db DB
config *config.Config
+ log *logrus.Logger
}
-func newFederatingDB(db DB, config *config.Config) pub.Database {
+func NewFederatingDB(db DB, config *config.Config, log *logrus.Logger) pub.Database {
return &federatingDB{
locks: new(sync.Map),
db: db,
config: config,
+ log: log,
}
}
@@ -98,7 +104,30 @@ func (f *federatingDB) Unlock(c context.Context, id *url.URL) error {
//
// The library makes this call only after acquiring a lock first.
func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) {
- return false, nil
+
+ if !util.IsInboxPath(inbox) {
+ return false, fmt.Errorf("%s is not an inbox URI", inbox.String())
+ }
+
+ if !util.IsStatusesPath(id) {
+ return false, fmt.Errorf("%s is not a status URI", id.String())
+ }
+ _, statusID, err := util.ParseStatusesPath(inbox)
+ if err != nil {
+ return false, fmt.Errorf("status URI %s was not parseable: %s", id.String(), err)
+ }
+
+ if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil {
+ if _, ok := err.(ErrNoEntries); ok {
+ // we don't have it
+ return false, nil
+ }
+ // actual error
+ return false, fmt.Errorf("error getting status from db: %s", err)
+ }
+
+ // we must have it
+ return true, nil
}
// GetInbox returns the first ordered collection page of the outbox at
@@ -118,26 +147,86 @@ func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOr
return nil
}
-// Owns returns true if the database has an entry for the IRI and it
-// exists in the database.
-//
+// Owns returns true if the IRI belongs to this instance, and if
+// the database has an entry for the IRI.
// The library makes this call only after acquiring a lock first.
-func (f *federatingDB) Owns(c context.Context, id *url.URL) (owns bool, err error) {
- return false, nil
+func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) {
+ // if the id host isn't this instance host, we don't own this IRI
+ if id.Host != f.config.Host {
+ return false, nil
+ }
+
+ // apparently we own it, so what *is* it?
+
+ // check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS
+ if util.IsStatusesPath(id) {
+ _, uid, err := util.ParseStatusesPath(id)
+ if err != nil {
+ return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
+ }
+ if err := f.db.GetWhere("uri", uid, >smodel.Status{}); err != nil {
+ if _, ok := err.(ErrNoEntries); ok {
+ // there are no entries for this status
+ return false, nil
+ }
+ // an actual error happened
+ return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err)
+ }
+ return true, nil
+ }
+
+ // check if it's a user, eg /users/example_username
+ if util.IsUserPath(id) {
+ username, err := util.ParseUserPath(id)
+ if err != nil {
+ return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err)
+ }
+ if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil {
+ if _, ok := err.(ErrNoEntries); ok {
+ // there are no entries for this username
+ return false, nil
+ }
+ // an actual error happened
+ return false, fmt.Errorf("database error fetching account with username %s: %s", username, err)
+ }
+ return true, nil
+ }
+
+ return false, fmt.Errorf("could not match activityID: %s", id.String())
}
// ActorForOutbox fetches the actor's IRI for the given outbox IRI.
//
// The library makes this call only after acquiring a lock first.
func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) {
- return nil, nil
+ if !util.IsOutboxPath(outboxIRI) {
+ return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String())
+ }
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("outbox_uri", outboxIRI.String(), acct); err != nil {
+ if _, ok := err.(ErrNoEntries); ok {
+ return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String())
+ }
+ return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String())
+ }
+ return url.Parse(acct.URI)
}
// ActorForInbox fetches the actor's IRI for the given outbox IRI.
//
// The library makes this call only after acquiring a lock first.
func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) {
- return nil, nil
+ if !util.IsInboxPath(inboxIRI) {
+ return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
+ }
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
+ if _, ok := err.(ErrNoEntries); ok {
+ return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
+ }
+ return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
+ }
+ return url.Parse(acct.URI)
}
// OutboxForInbox fetches the corresponding actor's outbox IRI for the
@@ -145,7 +234,17 @@ func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (acto
//
// The library makes this call only after acquiring a lock first.
func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) {
- return nil, nil
+ if !util.IsInboxPath(inboxIRI) {
+ return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String())
+ }
+ acct := >smodel.Account{}
+ if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil {
+ if _, ok := err.(ErrNoEntries); ok {
+ return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String())
+ }
+ return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String())
+ }
+ return url.Parse(acct.OutboxURI)
}
// Exists returns true if the database has an entry for the specified
diff --git a/internal/db/gtsmodel/account.go b/internal/db/gtsmodel/account.go
@@ -1,142 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-// Package gtsmodel contains types used *internally* by GoToSocial and added/removed/selected from the database.
-// These types should never be serialized and/or sent out via public APIs, as they contain sensitive information.
-// The annotation used on these structs is for handling them via the go-pg ORM (hence why they're in this db subdir).
-// See here for more info on go-pg model annotations: https://pg.uptrace.dev/models/
-package gtsmodel
-
-import (
- "crypto/rsa"
- "time"
-)
-
-// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc)
-type Account struct {
- /*
- BASIC INFO
- */
-
- // id of this account in the local database; the end-user will never need to know this, it's strictly internal
- ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
- // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org``
- Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other
- // Domain of the account, will be null if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username.
- Domain string `pg:",unique:userdomain"` // username and domain should be unique *with* each other
-
- /*
- ACCOUNT METADATA
- */
-
- // ID of the avatar as a media attachment
- AvatarMediaAttachmentID string
- // ID of the header as a media attachment
- HeaderMediaAttachmentID string
- // DisplayName for this account. Can be empty, then just the Username will be used for display purposes.
- DisplayName string
- // a key/value map of fields that this account has added to their profile
- Fields []Field
- // A note that this account has on their profile (ie., the account's bio/description of themselves)
- Note string
- // Is this a memorial account, ie., has the user passed away?
- Memorial bool
- // This account has moved this account id in the database
- MovedToAccountID string
- // When was this account created?
- CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // When was this account last updated?
- UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // Does this account identify itself as a bot?
- Bot bool
- // What reason was given for signing up when this account was created?
- Reason string
-
- /*
- USER AND PRIVACY PREFERENCES
- */
-
- // Does this account need an approval for new followers?
- Locked bool
- // Should this account be shown in the instance's profile directory?
- Discoverable bool
- // Default post privacy for this account
- Privacy Visibility
- // Set posts from this account to sensitive by default?
- Sensitive bool
- // What language does this account post in?
- Language string
-
- /*
- ACTIVITYPUB THINGS
- */
-
- // What is the activitypub URI for this account discovered by webfinger?
- URI string `pg:",unique"`
- // At which URL can we see the user account in a web browser?
- URL string `pg:",unique"`
- // Last time this account was located using the webfinger API.
- LastWebfingeredAt time.Time `pg:"type:timestamp"`
- // Address of this account's activitypub inbox, for sending activity to
- InboxURL string `pg:",unique"`
- // Address of this account's activitypub outbox
- OutboxURL string `pg:",unique"`
- // Don't support shared inbox right now so this is just a stub for a future implementation
- SharedInboxURL string `pg:",unique"`
- // URL for getting the followers list of this account
- FollowersURL string `pg:",unique"`
- // URL for getting the featured collection list of this account
- FeaturedCollectionURL string `pg:",unique"`
- // What type of activitypub actor is this account?
- ActorType ActivityStreamsActor
- // This account is associated with x account id
- AlsoKnownAs string
-
- /*
- CRYPTO FIELDS
- */
-
- // Privatekey for validating activitypub requests, will obviously only be defined for local accounts
- PrivateKey *rsa.PrivateKey
- // Publickey for encoding activitypub requests, will be defined for both local and remote accounts
- PublicKey *rsa.PublicKey
-
- /*
- ADMIN FIELDS
- */
-
- // When was this account set to have all its media shown as sensitive?
- SensitizedAt time.Time `pg:"type:timestamp"`
- // When was this account silenced (eg., statuses only visible to followers, not public)?
- SilencedAt time.Time `pg:"type:timestamp"`
- // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account)
- SuspendedAt time.Time `pg:"type:timestamp"`
- // Should we hide this account's collections?
- HideCollections bool
- // id of the user that suspended this account through an admin action
- SuspensionOrigin string
-}
-
-// Field represents a key value field on an account, for things like pronouns, website, etc.
-// VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the
-// username of the user.
-type Field struct {
- Name string
- Value string
- VerifiedAt time.Time `pg:"type:timestamp"`
-}
diff --git a/internal/db/gtsmodel/emoji.go b/internal/db/gtsmodel/emoji.go
@@ -1,75 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package gtsmodel
-
-import "time"
-
-// Emoji represents a custom emoji that's been uploaded through the admin UI, and is useable by instance denizens.
-type Emoji struct {
- // database ID of this emoji
- ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"`
- // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_
- // eg., 'blob_hug' 'purple_heart' Must be unique with domain.
- Shortcode string `pg:",notnull,unique:shortcodedomain"`
- // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
- Domain string `pg:",notnull,default:'',use_zero,unique:shortcodedomain"`
- // When was this emoji created. Must be unique with shortcode.
- CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // When was this emoji updated
- UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // Where can this emoji be retrieved remotely? Null for local emojis.
- // For remote emojis, it'll be something like:
- // https://hackers.town/system/custom_emojis/images/000/049/842/original/1b74481204feabfd.png
- ImageRemoteURL string
- // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
- // For remote emojis, it'll be something like:
- // https://hackers.town/system/custom_emojis/images/000/049/842/static/1b74481204feabfd.png
- ImageStaticRemoteURL string
- // Where can this emoji be retrieved from the local server? Null for remote emojis.
- // Assuming our server is hosted at 'example.org', this will be something like:
- // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
- ImageURL string
- // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
- // Assuming our server is hosted at 'example.org', this will be something like:
- // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
- ImageStaticURL string
- // Path of the emoji image in the server storage system. Will be something like:
- // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
- ImagePath string `pg:",notnull"`
- // Path of a static version of the emoji image in the server storage system. Will be something like:
- // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
- ImageStaticPath string `pg:",notnull"`
- // MIME content type of the emoji image
- // Probably "image/png"
- ImageContentType string `pg:",notnull"`
- // Size of the emoji image file in bytes, for serving purposes.
- ImageFileSize int `pg:",notnull"`
- // Size of the static version of the emoji image file in bytes, for serving purposes.
- ImageStaticFileSize int `pg:",notnull"`
- // When was the emoji image last updated?
- ImageUpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // Has a moderation action disabled this emoji from being shown?
- Disabled bool `pg:",notnull,default:false"`
- // ActivityStreams uri of this emoji. Something like 'https://example.org/emojis/1234'
- URI string `pg:",notnull,unique"`
- // Is this emoji visible in the admin emoji picker?
- VisibleInPicker bool `pg:",notnull,default:true"`
- // In which emoji category is this emoji visible?
- CategoryID string
-}
diff --git a/internal/db/gtsmodel/mediaattachment.go b/internal/db/gtsmodel/mediaattachment.go
@@ -1,150 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package gtsmodel
-
-import (
- "time"
-)
-
-// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is
-// somewhere in storage and that can be retrieved and served by the router.
-type MediaAttachment struct {
- // ID of the attachment in the database
- ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
- // ID of the status to which this is attached
- StatusID string
- // Where can the attachment be retrieved on *this* server
- URL string
- // Where can the attachment be retrieved on a remote server (empty for local media)
- RemoteURL string
- // When was the attachment created
- CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // When was the attachment last updated
- UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // Type of file (image/gif/audio/video)
- Type FileType `pg:",notnull"`
- // Metadata about the file
- FileMeta FileMeta
- // To which account does this attachment belong
- AccountID string `pg:",notnull"`
- // Description of the attachment (for screenreaders)
- Description string
- // To which scheduled status does this attachment belong
- ScheduledStatusID string
- // What is the generated blurhash of this attachment
- Blurhash string
- // What is the processing status of this attachment
- Processing ProcessingStatus
- // metadata for the whole file
- File File
- // small image thumbnail derived from a larger image, video, or audio file.
- Thumbnail Thumbnail
- // Is this attachment being used as an avatar?
- Avatar bool
- // Is this attachment being used as a header?
- Header bool
-}
-
-// File refers to the metadata for the whole file
-type File struct {
- // What is the path of the file in storage.
- Path string
- // What is the MIME content type of the file.
- ContentType string
- // What is the size of the file in bytes.
- FileSize int
- // When was the file last updated.
- UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
-}
-
-// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file.
-type Thumbnail struct {
- // What is the path of the file in storage
- Path string
- // What is the MIME content type of the file.
- ContentType string
- // What is the size of the file in bytes
- FileSize int
- // When was the file last updated
- UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
- // What is the URL of the thumbnail on the local server
- URL string
- // What is the remote URL of the thumbnail (empty for local media)
- RemoteURL string
-}
-
-// ProcessingStatus refers to how far along in the processing stage the attachment is.
-type ProcessingStatus int
-
-const (
- // ProcessingStatusReceived indicates the attachment has been received and is awaiting processing. No thumbnail available yet.
- ProcessingStatusReceived ProcessingStatus = 0
- // ProcessingStatusProcessing indicates the attachment is currently being processed. Thumbnail is available but full media is not.
- ProcessingStatusProcessing ProcessingStatus = 1
- // ProcessingStatusProcessed indicates the attachment has been fully processed and is ready to be served.
- ProcessingStatusProcessed ProcessingStatus = 2
- // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted.
- ProcessingStatusError ProcessingStatus = 666
-)
-
-// FileType refers to the file type of the media attaachment.
-type FileType string
-
-const (
- // FileTypeImage is for jpegs and pngs
- FileTypeImage FileType = "image"
- // FileTypeGif is for native gifs and soundless videos that have been converted to gifs
- FileTypeGif FileType = "gif"
- // FileTypeAudio is for audio-only files (no video)
- FileTypeAudio FileType = "audio"
- // FileTypeVideo is for files with audio + visual
- FileTypeVideo FileType = "video"
- // FileTypeUnknown is for unknown file types (surprise surprise!)
- FileTypeUnknown FileType = "unknown"
-)
-
-// FileMeta describes metadata about the actual contents of the file.
-type FileMeta struct {
- Original Original
- Small Small
- Focus Focus
-}
-
-// Small can be used for a thumbnail of any media type
-type Small struct {
- Width int
- Height int
- Size int
- Aspect float64
-}
-
-// Original can be used for original metadata for any media type
-type Original struct {
- Width int
- Height int
- Size int
- Aspect float64
-}
-
-// Focus describes the 'center' of the image for display purposes.
-// X and Y should each be between -1 and 1
-type Focus struct {
- X float32
- Y float32
-}
diff --git a/internal/db/mock_DB.go b/internal/db/mock_DB.go
@@ -1,484 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package db
-
-import (
- context "context"
-
- mock "github.com/stretchr/testify/mock"
- gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-
- net "net"
-
- pub "github.com/go-fed/activity/pub"
-)
-
-// MockDB is an autogenerated mock type for the DB type
-type MockDB struct {
- mock.Mock
-}
-
-// Blocked provides a mock function with given fields: account1, account2
-func (_m *MockDB) Blocked(account1 string, account2 string) (bool, error) {
- ret := _m.Called(account1, account2)
-
- var r0 bool
- if rf, ok := ret.Get(0).(func(string, string) bool); ok {
- r0 = rf(account1, account2)
- } else {
- r0 = ret.Get(0).(bool)
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(string, string) error); ok {
- r1 = rf(account1, account2)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// CreateTable provides a mock function with given fields: i
-func (_m *MockDB) CreateTable(i interface{}) error {
- ret := _m.Called(i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(interface{}) error); ok {
- r0 = rf(i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// DeleteByID provides a mock function with given fields: id, i
-func (_m *MockDB) DeleteByID(id string, i interface{}) error {
- ret := _m.Called(id, i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
- r0 = rf(id, i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// DeleteWhere provides a mock function with given fields: key, value, i
-func (_m *MockDB) DeleteWhere(key string, value interface{}, i interface{}) error {
- ret := _m.Called(key, value, i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok {
- r0 = rf(key, value, i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// DropTable provides a mock function with given fields: i
-func (_m *MockDB) DropTable(i interface{}) error {
- ret := _m.Called(i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(interface{}) error); ok {
- r0 = rf(i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// EmojiStringsToEmojis provides a mock function with given fields: emojis, originAccountID, statusID
-func (_m *MockDB) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) {
- ret := _m.Called(emojis, originAccountID, statusID)
-
- var r0 []*gtsmodel.Emoji
- if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Emoji); ok {
- r0 = rf(emojis, originAccountID, statusID)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).([]*gtsmodel.Emoji)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
- r1 = rf(emojis, originAccountID, statusID)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// Federation provides a mock function with given fields:
-func (_m *MockDB) Federation() pub.Database {
- ret := _m.Called()
-
- var r0 pub.Database
- if rf, ok := ret.Get(0).(func() pub.Database); ok {
- r0 = rf()
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(pub.Database)
- }
- }
-
- return r0
-}
-
-// GetAccountByUserID provides a mock function with given fields: userID, account
-func (_m *MockDB) GetAccountByUserID(userID string, account *gtsmodel.Account) error {
- ret := _m.Called(userID, account)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, *gtsmodel.Account) error); ok {
- r0 = rf(userID, account)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetAll provides a mock function with given fields: i
-func (_m *MockDB) GetAll(i interface{}) error {
- ret := _m.Called(i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(interface{}) error); ok {
- r0 = rf(i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetAvatarForAccountID provides a mock function with given fields: avatar, accountID
-func (_m *MockDB) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error {
- ret := _m.Called(avatar, accountID)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
- r0 = rf(avatar, accountID)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetByID provides a mock function with given fields: id, i
-func (_m *MockDB) GetByID(id string, i interface{}) error {
- ret := _m.Called(id, i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
- r0 = rf(id, i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetFollowRequestsForAccountID provides a mock function with given fields: accountID, followRequests
-func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error {
- ret := _m.Called(accountID, followRequests)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.FollowRequest) error); ok {
- r0 = rf(accountID, followRequests)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetFollowersByAccountID provides a mock function with given fields: accountID, followers
-func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error {
- ret := _m.Called(accountID, followers)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok {
- r0 = rf(accountID, followers)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetFollowingByAccountID provides a mock function with given fields: accountID, following
-func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error {
- ret := _m.Called(accountID, following)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok {
- r0 = rf(accountID, following)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetHeaderForAccountID provides a mock function with given fields: header, accountID
-func (_m *MockDB) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error {
- ret := _m.Called(header, accountID)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
- r0 = rf(header, accountID)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetLastStatusForAccountID provides a mock function with given fields: accountID, status
-func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error {
- ret := _m.Called(accountID, status)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, *gtsmodel.Status) error); ok {
- r0 = rf(accountID, status)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetStatusesByAccountID provides a mock function with given fields: accountID, statuses
-func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error {
- ret := _m.Called(accountID, statuses)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status) error); ok {
- r0 = rf(accountID, statuses)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetStatusesByTimeDescending provides a mock function with given fields: accountID, statuses, limit
-func (_m *MockDB) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error {
- ret := _m.Called(accountID, statuses, limit)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status, int) error); ok {
- r0 = rf(accountID, statuses, limit)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// GetWhere provides a mock function with given fields: key, value, i
-func (_m *MockDB) GetWhere(key string, value interface{}, i interface{}) error {
- ret := _m.Called(key, value, i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok {
- r0 = rf(key, value, i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// IsEmailAvailable provides a mock function with given fields: email
-func (_m *MockDB) IsEmailAvailable(email string) error {
- ret := _m.Called(email)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string) error); ok {
- r0 = rf(email)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// IsHealthy provides a mock function with given fields: ctx
-func (_m *MockDB) IsHealthy(ctx context.Context) error {
- ret := _m.Called(ctx)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context) error); ok {
- r0 = rf(ctx)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// IsUsernameAvailable provides a mock function with given fields: username
-func (_m *MockDB) IsUsernameAvailable(username string) error {
- ret := _m.Called(username)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string) error); ok {
- r0 = rf(username)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MentionStringsToMentions provides a mock function with given fields: targetAccounts, originAccountID, statusID
-func (_m *MockDB) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) {
- ret := _m.Called(targetAccounts, originAccountID, statusID)
-
- var r0 []*gtsmodel.Mention
- if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Mention); ok {
- r0 = rf(targetAccounts, originAccountID, statusID)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).([]*gtsmodel.Mention)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
- r1 = rf(targetAccounts, originAccountID, statusID)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// NewSignup provides a mock function with given fields: username, reason, requireApproval, email, password, signUpIP, locale, appID
-func (_m *MockDB) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) {
- ret := _m.Called(username, reason, requireApproval, email, password, signUpIP, locale, appID)
-
- var r0 *gtsmodel.User
- if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *gtsmodel.User); ok {
- r0 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*gtsmodel.User)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(string, string, bool, string, string, net.IP, string, string) error); ok {
- r1 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// Put provides a mock function with given fields: i
-func (_m *MockDB) Put(i interface{}) error {
- ret := _m.Called(i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(interface{}) error); ok {
- r0 = rf(i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// SetHeaderOrAvatarForAccountID provides a mock function with given fields: mediaAttachment, accountID
-func (_m *MockDB) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error {
- ret := _m.Called(mediaAttachment, accountID)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok {
- r0 = rf(mediaAttachment, accountID)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// Stop provides a mock function with given fields: ctx
-func (_m *MockDB) Stop(ctx context.Context) error {
- ret := _m.Called(ctx)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context) error); ok {
- r0 = rf(ctx)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// TagStringsToTags provides a mock function with given fields: tags, originAccountID, statusID
-func (_m *MockDB) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) {
- ret := _m.Called(tags, originAccountID, statusID)
-
- var r0 []*gtsmodel.Tag
- if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Tag); ok {
- r0 = rf(tags, originAccountID, statusID)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).([]*gtsmodel.Tag)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func([]string, string, string) error); ok {
- r1 = rf(tags, originAccountID, statusID)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// UpdateByID provides a mock function with given fields: id, i
-func (_m *MockDB) UpdateByID(id string, i interface{}) error {
- ret := _m.Called(id, i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
- r0 = rf(id, i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// UpdateOneByID provides a mock function with given fields: id, key, value, i
-func (_m *MockDB) UpdateOneByID(id string, key string, value interface{}, i interface{}) error {
- ret := _m.Called(id, key, value, i)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(string, string, interface{}, interface{}) error); ok {
- r0 = rf(id, key, value, i)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
diff --git a/internal/db/pg.go b/internal/db/pg.go
@@ -37,7 +37,7 @@ import (
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
"golang.org/x/crypto/bcrypt"
)
@@ -46,14 +46,14 @@ import (
type postgresService struct {
config *config.Config
conn *pg.DB
- log *logrus.Entry
+ log *logrus.Logger
cancel context.CancelFunc
federationDB pub.Database
}
-// newPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface.
+// NewPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface.
// Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection.
-func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry) (DB, error) {
+func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) {
opts, err := derivePGOptions(c)
if err != nil {
return nil, fmt.Errorf("could not create postgres service: %s", err)
@@ -67,7 +67,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
// this will break the logfmt format we normally log in,
// since we can't choose where pg outputs to and it defaults to
// stdout. So use this option with care!
- if log.Logger.GetLevel() >= logrus.TraceLevel {
+ if log.GetLevel() >= logrus.TraceLevel {
conn.AddQueryHook(pgdebug.DebugHook{
// Print all queries.
Verbose: true,
@@ -95,7 +95,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
cancel: cancel,
}
- federatingDB := newFederatingDB(ps, c)
+ federatingDB := NewFederatingDB(ps, c, log)
ps.federationDB = federatingDB
// we can confidently return this useable postgres service now
@@ -109,8 +109,8 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry
// derivePGOptions takes an application config and returns either a ready-to-use *pg.Options
// with sensible defaults, or an error if it's not satisfied by the provided config.
func derivePGOptions(c *config.Config) (*pg.Options, error) {
- if strings.ToUpper(c.DBConfig.Type) != dbTypePostgres {
- return nil, fmt.Errorf("expected db type of %s but got %s", dbTypePostgres, c.DBConfig.Type)
+ if strings.ToUpper(c.DBConfig.Type) != DBTypePostgres {
+ return nil, fmt.Errorf("expected db type of %s but got %s", DBTypePostgres, c.DBConfig.Type)
}
// validate port
@@ -341,6 +341,16 @@ func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.A
return nil
}
+func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error {
+ if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil {
+ if err == pg.ErrNoRows {
+ return ErrNoEntries{}
+ }
+ return err
+ }
+ return nil
+}
+
func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error {
if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil {
if err == pg.ErrNoRows {
@@ -456,21 +466,23 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr
return nil, err
}
- uris := util.GenerateURIs(username, ps.config.Protocol, ps.config.Host)
+ newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host)
a := >smodel.Account{
Username: username,
DisplayName: username,
Reason: reason,
- URL: uris.UserURL,
+ URL: newAccountURIs.UserURL,
PrivateKey: key,
PublicKey: &key.PublicKey,
+ PublicKeyURI: newAccountURIs.PublicKeyURI,
ActorType: gtsmodel.ActivityStreamsPerson,
- URI: uris.UserURI,
- InboxURL: uris.InboxURI,
- OutboxURL: uris.OutboxURI,
- FollowersURL: uris.FollowersURI,
- FeaturedCollectionURL: uris.CollectionURI,
+ URI: newAccountURIs.UserURI,
+ InboxURI: newAccountURIs.InboxURI,
+ OutboxURI: newAccountURIs.OutboxURI,
+ FollowersURI: newAccountURIs.FollowersURI,
+ FollowingURI: newAccountURIs.FollowingURI,
+ FeaturedCollectionURI: newAccountURIs.CollectionURI,
}
if _, err = ps.conn.Model(a).Insert(); err != nil {
return nil, err
@@ -566,6 +578,7 @@ func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachmen
}
func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) {
+ // TODO: check domain blocks as well
var blocked bool
if err := ps.conn.Model(>smodel.Block{}).
Where("account_id = ?", account1).Where("target_account_id = ?", account2).
diff --git a/internal/db/pg_test.go b/internal/db/pg_test.go
@@ -16,6 +16,6 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-package db
+package db_test
// TODO: write tests for postgres
diff --git a/internal/distributor/distributor.go b/internal/distributor/distributor.go
@@ -1,110 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package distributor
-
-import (
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
-)
-
-// Distributor should be passed to api modules (see internal/apimodule/...). It is used for
-// passing messages back and forth from the client API and the federating interface, via channels.
-// It also contains logic for filtering which messages should end up where.
-// It is designed to be used asynchronously: the client API and the federating API should just be able to
-// fire messages into the distributor and not wait for a reply before proceeding with other work. This allows
-// for clean distribution of messages without slowing down the client API and harming the user experience.
-type Distributor interface {
- // FromClientAPI returns a channel for accepting messages that come from the gts client API.
- FromClientAPI() chan FromClientAPI
- // ClientAPIOut returns a channel for putting in messages that need to go to the gts client API.
- ToClientAPI() chan ToClientAPI
- // Start starts the Distributor, reading from its channels and passing messages back and forth.
- Start() error
- // Stop stops the distributor cleanly, finishing handling any remaining messages before closing down.
- Stop() error
-}
-
-// distributor just implements the Distributor interface
-type distributor struct {
- // federator pub.FederatingActor
- fromClientAPI chan FromClientAPI
- toClientAPI chan ToClientAPI
- stop chan interface{}
- log *logrus.Logger
-}
-
-// New returns a new Distributor that uses the given federator and logger
-func New(log *logrus.Logger) Distributor {
- return &distributor{
- // federator: federator,
- fromClientAPI: make(chan FromClientAPI, 100),
- toClientAPI: make(chan ToClientAPI, 100),
- stop: make(chan interface{}),
- log: log,
- }
-}
-
-// ClientAPIIn returns a channel for accepting messages that come from the gts client API.
-func (d *distributor) FromClientAPI() chan FromClientAPI {
- return d.fromClientAPI
-}
-
-// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API.
-func (d *distributor) ToClientAPI() chan ToClientAPI {
- return d.toClientAPI
-}
-
-// Start starts the Distributor, reading from its channels and passing messages back and forth.
-func (d *distributor) Start() error {
- go func() {
- DistLoop:
- for {
- select {
- case clientMsg := <-d.fromClientAPI:
- d.log.Infof("received message FROM client API: %+v", clientMsg)
- case clientMsg := <-d.toClientAPI:
- d.log.Infof("received message TO client API: %+v", clientMsg)
- case <-d.stop:
- break DistLoop
- }
- }
- }()
- return nil
-}
-
-// Stop stops the distributor cleanly, finishing handling any remaining messages before closing down.
-// TODO: empty message buffer properly before stopping otherwise we'll lose federating messages.
-func (d *distributor) Stop() error {
- close(d.stop)
- return nil
-}
-
-// FromClientAPI wraps a message that travels from the client API into the distributor
-type FromClientAPI struct {
- APObjectType gtsmodel.ActivityStreamsObject
- APActivityType gtsmodel.ActivityStreamsActivity
- Activity interface{}
-}
-
-// ToClientAPI wraps a message that travels from the distributor into the client API
-type ToClientAPI struct {
- APObjectType gtsmodel.ActivityStreamsObject
- APActivityType gtsmodel.ActivityStreamsActivity
- Activity interface{}
-}
diff --git a/internal/distributor/mock_Distributor.go b/internal/distributor/mock_Distributor.go
@@ -1,70 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package distributor
-
-import mock "github.com/stretchr/testify/mock"
-
-// MockDistributor is an autogenerated mock type for the Distributor type
-type MockDistributor struct {
- mock.Mock
-}
-
-// FromClientAPI provides a mock function with given fields:
-func (_m *MockDistributor) FromClientAPI() chan FromClientAPI {
- ret := _m.Called()
-
- var r0 chan FromClientAPI
- if rf, ok := ret.Get(0).(func() chan FromClientAPI); ok {
- r0 = rf()
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(chan FromClientAPI)
- }
- }
-
- return r0
-}
-
-// Start provides a mock function with given fields:
-func (_m *MockDistributor) Start() error {
- ret := _m.Called()
-
- var r0 error
- if rf, ok := ret.Get(0).(func() error); ok {
- r0 = rf()
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// Stop provides a mock function with given fields:
-func (_m *MockDistributor) Stop() error {
- ret := _m.Called()
-
- var r0 error
- if rf, ok := ret.Get(0).(func() error); ok {
- r0 = rf()
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// ToClientAPI provides a mock function with given fields:
-func (_m *MockDistributor) ToClientAPI() chan ToClientAPI {
- ret := _m.Called()
-
- var r0 chan ToClientAPI
- if rf, ok := ret.Get(0).(func() chan ToClientAPI); ok {
- r0 = rf()
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(chan ToClientAPI)
- }
- }
-
- return r0
-}
diff --git a/internal/federation/clock.go b/internal/federation/clock.go
@@ -0,0 +1,42 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package federation
+
+import (
+ "time"
+
+ "github.com/go-fed/activity/pub"
+)
+
+/*
+ GOFED CLOCK INTERFACE
+ Determines the time.
+*/
+
+// Clock implements the Clock interface of go-fed
+type Clock struct{}
+
+// Now just returns the time now
+func (c *Clock) Now() time.Time {
+ return time.Now()
+}
+
+func NewClock() pub.Clock {
+ return &Clock{}
+}
diff --git a/internal/federation/commonbehavior.go b/internal/federation/commonbehavior.go
@@ -0,0 +1,152 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package federation
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/go-fed/activity/streams/vocab"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+/*
+ GOFED COMMON BEHAVIOR INTERFACE
+ Contains functions required for both the Social API and Federating Protocol.
+ It is passed to the library as a dependency injection from the client
+ application.
+*/
+
+// AuthenticateGetInbox delegates the authentication of a GET to an
+// inbox.
+//
+// Always called, regardless whether the Federated Protocol or Social
+// API is enabled.
+//
+// If an error is returned, it is passed back to the caller of
+// GetInbox. In this case, the implementation must not write a
+// response to the ResponseWriter as is expected that the client will
+// do so when handling the error. The 'authenticated' is ignored.
+//
+// If no error is returned, but authentication or authorization fails,
+// then authenticated must be false and error nil. It is expected that
+// the implementation handles writing to the ResponseWriter in this
+// case.
+//
+// Finally, if the authentication and authorization succeeds, then
+// authenticated must be true and error nil. The request will continue
+// to be processed.
+func (f *federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
+ // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+ // the CLIENT API, not through the federation API, so we just do nothing here.
+ return nil, false, nil
+}
+
+// AuthenticateGetOutbox delegates the authentication of a GET to an
+// outbox.
+//
+// Always called, regardless whether the Federated Protocol or Social
+// API is enabled.
+//
+// If an error is returned, it is passed back to the caller of
+// GetOutbox. In this case, the implementation must not write a
+// response to the ResponseWriter as is expected that the client will
+// do so when handling the error. The 'authenticated' is ignored.
+//
+// If no error is returned, but authentication or authorization fails,
+// then authenticated must be false and error nil. It is expected that
+// the implementation handles writing to the ResponseWriter in this
+// case.
+//
+// Finally, if the authentication and authorization succeeds, then
+// authenticated must be true and error nil. The request will continue
+// to be processed.
+func (f *federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
+ // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+ // the CLIENT API, not through the federation API, so we just do nothing here.
+ return nil, false, nil
+}
+
+// GetOutbox returns the OrderedCollection inbox of the actor for this
+// context. It is up to the implementation to provide the correct
+// collection for the kind of authorization given in the request.
+//
+// AuthenticateGetOutbox will be called prior to this.
+//
+// Always called, regardless whether the Federated Protocol or Social
+// API is enabled.
+func (f *federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
+ // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+ // the CLIENT API, not through the federation API, so we just do nothing here.
+ return nil, nil
+}
+
+// NewTransport returns a new Transport on behalf of a specific actor.
+//
+// The actorBoxIRI will be either the inbox or outbox of an actor who is
+// attempting to do the dereferencing or delivery. Any authentication
+// scheme applied on the request must be based on this actor. The
+// request must contain some sort of credential of the user, such as a
+// HTTP Signature.
+//
+// The gofedAgent passed in should be used by the Transport
+// implementation in the User-Agent, as well as the application-specific
+// user agent string. The gofedAgent will indicate this library's use as
+// well as the library's version number.
+//
+// Any server-wide rate-limiting that needs to occur should happen in a
+// Transport implementation. This factory function allows this to be
+// created, so peer servers are not DOS'd.
+//
+// Any retry logic should also be handled by the Transport
+// implementation.
+//
+// Note that the library will not maintain a long-lived pointer to the
+// returned Transport so that any private credentials are able to be
+// garbage collected.
+func (f *federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) {
+
+ var username string
+ var err error
+
+ if util.IsInboxPath(actorBoxIRI) {
+ username, err = util.ParseInboxPath(actorBoxIRI)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't parse path %s as an inbox: %s", actorBoxIRI.String(), err)
+ }
+ } else if util.IsOutboxPath(actorBoxIRI) {
+ username, err = util.ParseOutboxPath(actorBoxIRI)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't parse path %s as an outbox: %s", actorBoxIRI.String(), err)
+ }
+ } else {
+ return nil, fmt.Errorf("id %s was neither an inbox path nor an outbox path", actorBoxIRI.String())
+ }
+
+ account := >smodel.Account{}
+ if err := f.db.GetLocalAccountByUsername(username, account); err != nil {
+ return nil, fmt.Errorf("error getting account with username %s from the db: %s", username, err)
+ }
+
+ return f.transportController.NewTransport(account.PublicKeyURI, account.PrivateKey)
+}
diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go
@@ -0,0 +1,136 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package federation
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/go-fed/activity/streams/vocab"
+)
+
+// federatingActor implements the go-fed federating protocol interface
+type federatingActor struct {
+ actor pub.FederatingActor
+}
+
+// newFederatingProtocol returns the gotosocial implementation of the GTSFederatingProtocol interface
+func newFederatingActor(c pub.CommonBehavior, s2s pub.FederatingProtocol, db pub.Database, clock pub.Clock) pub.FederatingActor {
+ actor := pub.NewFederatingActor(c, s2s, db, clock)
+
+ return &federatingActor{
+ actor: actor,
+ }
+}
+
+// Send a federated activity.
+//
+// The provided url must be the outbox of the sender. All processing of
+// the activity occurs similarly to the C2S flow:
+// - If t is not an Activity, it is wrapped in a Create activity.
+// - A new ID is generated for the activity.
+// - The activity is added to the specified outbox.
+// - The activity is prepared and delivered to recipients.
+//
+// Note that this function will only behave as expected if the
+// implementation has been constructed to support federation. This
+// method will guaranteed work for non-custom Actors. For custom actors,
+// care should be used to not call this method if only C2S is supported.
+func (f *federatingActor) Send(c context.Context, outbox *url.URL, t vocab.Type) (pub.Activity, error) {
+ return f.actor.Send(c, outbox, t)
+}
+
+// PostInbox returns true if the request was handled as an ActivityPub
+// POST to an actor's inbox. If false, the request was not an
+// ActivityPub request and may still be handled by the caller in
+// another way, such as serving a web page.
+//
+// If the error is nil, then the ResponseWriter's headers and response
+// has already been written. If a non-nil error is returned, then no
+// response has been written.
+//
+// If the Actor was constructed with the Federated Protocol enabled,
+// side effects will occur.
+//
+// If the Federated Protocol is not enabled, writes the
+// http.StatusMethodNotAllowed status code in the response. No side
+// effects occur.
+func (f *federatingActor) PostInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+ return f.actor.PostInbox(c, w, r)
+}
+
+// GetInbox returns true if the request was handled as an ActivityPub
+// GET to an actor's inbox. If false, the request was not an ActivityPub
+// request and may still be handled by the caller in another way, such
+// as serving a web page.
+//
+// If the error is nil, then the ResponseWriter's headers and response
+// has already been written. If a non-nil error is returned, then no
+// response has been written.
+//
+// If the request is an ActivityPub request, the Actor will defer to the
+// application to determine the correct authorization of the request and
+// the resulting OrderedCollection to respond with. The Actor handles
+// serializing this OrderedCollection and responding with the correct
+// headers and http.StatusOK.
+func (f *federatingActor) GetInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+ return f.actor.GetInbox(c, w, r)
+}
+
+// PostOutbox returns true if the request was handled as an ActivityPub
+// POST to an actor's outbox. If false, the request was not an
+// ActivityPub request and may still be handled by the caller in another
+// way, such as serving a web page.
+//
+// If the error is nil, then the ResponseWriter's headers and response
+// has already been written. If a non-nil error is returned, then no
+// response has been written.
+//
+// If the Actor was constructed with the Social Protocol enabled, side
+// effects will occur.
+//
+// If the Social Protocol is not enabled, writes the
+// http.StatusMethodNotAllowed status code in the response. No side
+// effects occur.
+//
+// If the Social and Federated Protocol are both enabled, it will handle
+// the side effects of receiving an ActivityStream Activity, and then
+// federate the Activity to peers.
+func (f *federatingActor) PostOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+ return f.actor.PostOutbox(c, w, r)
+}
+
+// GetOutbox returns true if the request was handled as an ActivityPub
+// GET to an actor's outbox. If false, the request was not an
+// ActivityPub request.
+//
+// If the error is nil, then the ResponseWriter's headers and response
+// has already been written. If a non-nil error is returned, then no
+// response has been written.
+//
+// If the request is an ActivityPub request, the Actor will defer to the
+// application to determine the correct authorization of the request and
+// the resulting OrderedCollection to respond with. The Actor handles
+// serializing this OrderedCollection and responding with the correct
+// headers and http.StatusOK.
+func (f *federatingActor) GetOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
+ return f.actor.GetOutbox(c, w, r)
+}
diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go
@@ -0,0 +1,247 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package federation
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/go-fed/activity/streams/vocab"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+/*
+ GO FED FEDERATING PROTOCOL INTERFACE
+ FederatingProtocol contains behaviors an application needs to satisfy for the
+ full ActivityPub S2S implementation to be supported by this library.
+ It is only required if the client application wants to support the server-to-
+ server, or federating, protocol.
+ It is passed to the library as a dependency injection from the client
+ application.
+*/
+
+// PostInboxRequestBodyHook callback after parsing the request body for a federated request
+// to the Actor's inbox.
+//
+// Can be used to set contextual information based on the Activity
+// received.
+//
+// Only called if the Federated Protocol is enabled.
+//
+// Warning: Neither authentication nor authorization has taken place at
+// this time. Doing anything beyond setting contextual information is
+// strongly discouraged.
+//
+// If an error is returned, it is passed back to the caller of
+// PostInbox. In this case, the DelegateActor implementation must not
+// write a response to the ResponseWriter as is expected that the caller
+// to PostInbox will do so when handling the error.
+func (f *federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) {
+ l := f.log.WithFields(logrus.Fields{
+ "func": "PostInboxRequestBodyHook",
+ "useragent": r.UserAgent(),
+ "url": r.URL.String(),
+ })
+
+ if activity == nil {
+ err := errors.New("nil activity in PostInboxRequestBodyHook")
+ l.Debug(err)
+ return nil, err
+ }
+
+ ctxWithActivity := context.WithValue(ctx, util.APActivity, activity)
+ return ctxWithActivity, nil
+}
+
+// AuthenticatePostInbox delegates the authentication of a POST to an
+// inbox.
+//
+// If an error is returned, it is passed back to the caller of
+// PostInbox. In this case, the implementation must not write a
+// response to the ResponseWriter as is expected that the client will
+// do so when handling the error. The 'authenticated' is ignored.
+//
+// If no error is returned, but authentication or authorization fails,
+// then authenticated must be false and error nil. It is expected that
+// the implementation handles writing to the ResponseWriter in this
+// case.
+//
+// Finally, if the authentication and authorization succeeds, then
+// authenticated must be true and error nil. The request will continue
+// to be processed.
+func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
+ l := f.log.WithFields(logrus.Fields{
+ "func": "AuthenticatePostInbox",
+ "useragent": r.UserAgent(),
+ "url": r.URL.String(),
+ })
+ l.Trace("received request to authenticate")
+
+ requestedAccountI := ctx.Value(util.APAccount)
+ if requestedAccountI == nil {
+ return ctx, false, errors.New("requested account not set in context")
+ }
+
+ requestedAccount, ok := requestedAccountI.(*gtsmodel.Account)
+ if !ok || requestedAccount == nil {
+ return ctx, false, errors.New("requested account not parsebale from context")
+ }
+
+ publicKeyOwnerURI, err := f.AuthenticateFederatedRequest(requestedAccount.Username, r)
+ if err != nil {
+ l.Debugf("request not authenticated: %s", err)
+ return ctx, false, fmt.Errorf("not authenticated: %s", err)
+ }
+
+ requestingAccount := >smodel.Account{}
+ if err := f.db.GetWhere("uri", publicKeyOwnerURI.String(), requestingAccount); err != nil {
+ // there's been a proper error so return it
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return ctx, false, fmt.Errorf("error getting requesting account with public key id %s: %s", publicKeyOwnerURI.String(), err)
+ }
+
+ // we don't know this account (yet) so let's dereference it right now
+ // TODO: slow-fed
+ person, err := f.DereferenceRemoteAccount(requestedAccount.Username, publicKeyOwnerURI)
+ if err != nil {
+ return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err)
+ }
+
+ a, err := f.typeConverter.ASRepresentationToAccount(person)
+ if err != nil {
+ return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err)
+ }
+ requestingAccount = a
+ }
+
+ contextWithRequestingAccount := context.WithValue(ctx, util.APRequestingAccount, requestingAccount)
+
+ return contextWithRequestingAccount, true, nil
+}
+
+// Blocked should determine whether to permit a set of actors given by
+// their ids are able to interact with this particular end user due to
+// being blocked or other application-specific logic.
+//
+// If an error is returned, it is passed back to the caller of
+// PostInbox.
+//
+// If no error is returned, but authentication or authorization fails,
+// then blocked must be true and error nil. An http.StatusForbidden
+// will be written in the wresponse.
+//
+// Finally, if the authentication and authorization succeeds, then
+// blocked must be false and error nil. The request will continue
+// to be processed.
+func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) {
+ // TODO
+ return false, nil
+}
+
+// FederatingCallbacks returns the application logic that handles
+// ActivityStreams received from federating peers.
+//
+// Note that certain types of callbacks will be 'wrapped' with default
+// behaviors supported natively by the library. Other callbacks
+// compatible with streams.TypeResolver can be specified by 'other'.
+//
+// For example, setting the 'Create' field in the
+// FederatingWrappedCallbacks lets an application dependency inject
+// additional behaviors they want to take place, including the default
+// behavior supplied by this library. This is guaranteed to be compliant
+// with the ActivityPub Social protocol.
+//
+// To override the default behavior, instead supply the function in
+// 'other', which does not guarantee the application will be compliant
+// with the ActivityPub Social Protocol.
+//
+// Applications are not expected to handle every single ActivityStreams
+// type and extension. The unhandled ones are passed to DefaultCallback.
+func (f *federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) {
+ // TODO
+ return pub.FederatingWrappedCallbacks{}, nil, nil
+}
+
+// DefaultCallback is called for types that go-fed can deserialize but
+// are not handled by the application's callbacks returned in the
+// Callbacks method.
+//
+// Applications are not expected to handle every single ActivityStreams
+// type and extension, so the unhandled ones are passed to
+// DefaultCallback.
+func (f *federator) DefaultCallback(ctx context.Context, activity pub.Activity) error {
+ l := f.log.WithFields(logrus.Fields{
+ "func": "DefaultCallback",
+ "aptype": activity.GetTypeName(),
+ })
+ l.Debugf("received unhandle-able activity type so ignoring it")
+ return nil
+}
+
+// MaxInboxForwardingRecursionDepth determines how deep to search within
+// an activity to determine if inbox forwarding needs to occur.
+//
+// Zero or negative numbers indicate infinite recursion.
+func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {
+ // TODO
+ return 0
+}
+
+// MaxDeliveryRecursionDepth determines how deep to search within
+// collections owned by peers when they are targeted to receive a
+// delivery.
+//
+// Zero or negative numbers indicate infinite recursion.
+func (f *federator) MaxDeliveryRecursionDepth(ctx context.Context) int {
+ // TODO
+ return 0
+}
+
+// FilterForwarding allows the implementation to apply business logic
+// such as blocks, spam filtering, and so on to a list of potential
+// Collections and OrderedCollections of recipients when inbox
+// forwarding has been triggered.
+//
+// The activity is provided as a reference for more intelligent
+// logic to be used, but the implementation must not modify it.
+func (f *federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) {
+ // TODO
+ return nil, nil
+}
+
+// GetInbox returns the OrderedCollection inbox of the actor for this
+// context. It is up to the implementation to provide the correct
+// collection for the kind of authorization given in the request.
+//
+// AuthenticateGetInbox will be called prior to this.
+//
+// Always called, regardless whether the Federated Protocol or Social
+// API is enabled.
+func (f *federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
+ // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through
+ // the CLIENT API, not through the federation API, so we just do nothing here.
+ return nil, nil
+}
diff --git a/internal/federation/federation.go b/internal/federation/federation.go
@@ -1,303 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-// Package federation provides ActivityPub/federation functionality for GoToSocial
-package federation
-
-import (
- "context"
- "net/http"
- "net/url"
- "time"
-
- "github.com/go-fed/activity/pub"
- "github.com/go-fed/activity/streams/vocab"
- "github.com/sirupsen/logrus"
- "github.com/superseriousbusiness/gotosocial/internal/db"
-)
-
-// New returns a go-fed compatible federating actor
-func New(db db.DB, log *logrus.Logger) pub.FederatingActor {
- f := &Federator{
- db: db,
- }
- return pub.NewFederatingActor(f, f, db.Federation(), f)
-}
-
-// Federator implements several go-fed interfaces in one convenient location
-type Federator struct {
- db db.DB
-}
-
-/*
- GO FED FEDERATING PROTOCOL INTERFACE
- FederatingProtocol contains behaviors an application needs to satisfy for the
- full ActivityPub S2S implementation to be supported by this library.
- It is only required if the client application wants to support the server-to-
- server, or federating, protocol.
- It is passed to the library as a dependency injection from the client
- application.
-*/
-
-// PostInboxRequestBodyHook callback after parsing the request body for a federated request
-// to the Actor's inbox.
-//
-// Can be used to set contextual information based on the Activity
-// received.
-//
-// Only called if the Federated Protocol is enabled.
-//
-// Warning: Neither authentication nor authorization has taken place at
-// this time. Doing anything beyond setting contextual information is
-// strongly discouraged.
-//
-// If an error is returned, it is passed back to the caller of
-// PostInbox. In this case, the DelegateActor implementation must not
-// write a response to the ResponseWriter as is expected that the caller
-// to PostInbox will do so when handling the error.
-func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) {
- // TODO
- return nil, nil
-}
-
-// AuthenticatePostInbox delegates the authentication of a POST to an
-// inbox.
-//
-// If an error is returned, it is passed back to the caller of
-// PostInbox. In this case, the implementation must not write a
-// response to the ResponseWriter as is expected that the client will
-// do so when handling the error. The 'authenticated' is ignored.
-//
-// If no error is returned, but authentication or authorization fails,
-// then authenticated must be false and error nil. It is expected that
-// the implementation handles writing to the ResponseWriter in this
-// case.
-//
-// Finally, if the authentication and authorization succeeds, then
-// authenticated must be true and error nil. The request will continue
-// to be processed.
-func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
- // TODO
- return nil, false, nil
-}
-
-// Blocked should determine whether to permit a set of actors given by
-// their ids are able to interact with this particular end user due to
-// being blocked or other application-specific logic.
-//
-// If an error is returned, it is passed back to the caller of
-// PostInbox.
-//
-// If no error is returned, but authentication or authorization fails,
-// then blocked must be true and error nil. An http.StatusForbidden
-// will be written in the wresponse.
-//
-// Finally, if the authentication and authorization succeeds, then
-// blocked must be false and error nil. The request will continue
-// to be processed.
-func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) {
- // TODO
- return false, nil
-}
-
-// FederatingCallbacks returns the application logic that handles
-// ActivityStreams received from federating peers.
-//
-// Note that certain types of callbacks will be 'wrapped' with default
-// behaviors supported natively by the library. Other callbacks
-// compatible with streams.TypeResolver can be specified by 'other'.
-//
-// For example, setting the 'Create' field in the
-// FederatingWrappedCallbacks lets an application dependency inject
-// additional behaviors they want to take place, including the default
-// behavior supplied by this library. This is guaranteed to be compliant
-// with the ActivityPub Social protocol.
-//
-// To override the default behavior, instead supply the function in
-// 'other', which does not guarantee the application will be compliant
-// with the ActivityPub Social Protocol.
-//
-// Applications are not expected to handle every single ActivityStreams
-// type and extension. The unhandled ones are passed to DefaultCallback.
-func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) {
- // TODO
- return pub.FederatingWrappedCallbacks{}, nil, nil
-}
-
-// DefaultCallback is called for types that go-fed can deserialize but
-// are not handled by the application's callbacks returned in the
-// Callbacks method.
-//
-// Applications are not expected to handle every single ActivityStreams
-// type and extension, so the unhandled ones are passed to
-// DefaultCallback.
-func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity) error {
- // TODO
- return nil
-}
-
-// MaxInboxForwardingRecursionDepth determines how deep to search within
-// an activity to determine if inbox forwarding needs to occur.
-//
-// Zero or negative numbers indicate infinite recursion.
-func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {
- // TODO
- return 0
-}
-
-// MaxDeliveryRecursionDepth determines how deep to search within
-// collections owned by peers when they are targeted to receive a
-// delivery.
-//
-// Zero or negative numbers indicate infinite recursion.
-func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int {
- // TODO
- return 0
-}
-
-// FilterForwarding allows the implementation to apply business logic
-// such as blocks, spam filtering, and so on to a list of potential
-// Collections and OrderedCollections of recipients when inbox
-// forwarding has been triggered.
-//
-// The activity is provided as a reference for more intelligent
-// logic to be used, but the implementation must not modify it.
-func (f *Federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) {
- // TODO
- return nil, nil
-}
-
-// GetInbox returns the OrderedCollection inbox of the actor for this
-// context. It is up to the implementation to provide the correct
-// collection for the kind of authorization given in the request.
-//
-// AuthenticateGetInbox will be called prior to this.
-//
-// Always called, regardless whether the Federated Protocol or Social
-// API is enabled.
-func (f *Federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
- // TODO
- return nil, nil
-}
-
-/*
- GOFED COMMON BEHAVIOR INTERFACE
- Contains functions required for both the Social API and Federating Protocol.
- It is passed to the library as a dependency injection from the client
- application.
-*/
-
-// AuthenticateGetInbox delegates the authentication of a GET to an
-// inbox.
-//
-// Always called, regardless whether the Federated Protocol or Social
-// API is enabled.
-//
-// If an error is returned, it is passed back to the caller of
-// GetInbox. In this case, the implementation must not write a
-// response to the ResponseWriter as is expected that the client will
-// do so when handling the error. The 'authenticated' is ignored.
-//
-// If no error is returned, but authentication or authorization fails,
-// then authenticated must be false and error nil. It is expected that
-// the implementation handles writing to the ResponseWriter in this
-// case.
-//
-// Finally, if the authentication and authorization succeeds, then
-// authenticated must be true and error nil. The request will continue
-// to be processed.
-func (f *Federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
- // TODO
- // use context.WithValue() and context.Value() to set and get values through here
- return nil, false, nil
-}
-
-// AuthenticateGetOutbox delegates the authentication of a GET to an
-// outbox.
-//
-// Always called, regardless whether the Federated Protocol or Social
-// API is enabled.
-//
-// If an error is returned, it is passed back to the caller of
-// GetOutbox. In this case, the implementation must not write a
-// response to the ResponseWriter as is expected that the client will
-// do so when handling the error. The 'authenticated' is ignored.
-//
-// If no error is returned, but authentication or authorization fails,
-// then authenticated must be false and error nil. It is expected that
-// the implementation handles writing to the ResponseWriter in this
-// case.
-//
-// Finally, if the authentication and authorization succeeds, then
-// authenticated must be true and error nil. The request will continue
-// to be processed.
-func (f *Federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) {
- // TODO
- return nil, false, nil
-}
-
-// GetOutbox returns the OrderedCollection inbox of the actor for this
-// context. It is up to the implementation to provide the correct
-// collection for the kind of authorization given in the request.
-//
-// AuthenticateGetOutbox will be called prior to this.
-//
-// Always called, regardless whether the Federated Protocol or Social
-// API is enabled.
-func (f *Federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
- // TODO
- return nil, nil
-}
-
-// NewTransport returns a new Transport on behalf of a specific actor.
-//
-// The actorBoxIRI will be either the inbox or outbox of an actor who is
-// attempting to do the dereferencing or delivery. Any authentication
-// scheme applied on the request must be based on this actor. The
-// request must contain some sort of credential of the user, such as a
-// HTTP Signature.
-//
-// The gofedAgent passed in should be used by the Transport
-// implementation in the User-Agent, as well as the application-specific
-// user agent string. The gofedAgent will indicate this library's use as
-// well as the library's version number.
-//
-// Any server-wide rate-limiting that needs to occur should happen in a
-// Transport implementation. This factory function allows this to be
-// created, so peer servers are not DOS'd.
-//
-// Any retry logic should also be handled by the Transport
-// implementation.
-//
-// Note that the library will not maintain a long-lived pointer to the
-// returned Transport so that any private credentials are able to be
-// garbage collected.
-func (f *Federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) {
- // TODO
- return nil, nil
-}
-
-/*
- GOFED CLOCK INTERFACE
- Determines the time.
-*/
-
-// Now returns the current time.
-func (f *Federator) Now() time.Time {
- return time.Now()
-}
diff --git a/internal/federation/federator.go b/internal/federation/federator.go
@@ -0,0 +1,79 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package federation
+
+import (
+ "net/http"
+ "net/url"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/transport"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// Federator wraps various interfaces and functions to manage activitypub federation from gotosocial
+type Federator interface {
+ // FederatingActor returns the underlying pub.FederatingActor, which can be used to send activities, and serve actors at inboxes/outboxes.
+ FederatingActor() pub.FederatingActor
+ // AuthenticateFederatedRequest can be used to check the authenticity of incoming http-signed requests for federating resources.
+ // The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments.
+ AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error)
+ // DereferenceRemoteAccount can be used to get the representation of a remote account, based on the account ID (which is a URI).
+ // The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments.
+ DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error)
+ // GetTransportForUser returns a new transport initialized with the key credentials belonging to the given username.
+ // This can be used for making signed http requests.
+ GetTransportForUser(username string) (pub.Transport, error)
+ pub.CommonBehavior
+ pub.FederatingProtocol
+}
+
+type federator struct {
+ config *config.Config
+ db db.DB
+ clock pub.Clock
+ typeConverter typeutils.TypeConverter
+ transportController transport.Controller
+ actor pub.FederatingActor
+ log *logrus.Logger
+}
+
+// NewFederator returns a new federator
+func NewFederator(db db.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator {
+
+ clock := &Clock{}
+ f := &federator{
+ config: config,
+ db: db,
+ clock: &Clock{},
+ typeConverter: typeConverter,
+ transportController: transportController,
+ log: log,
+ }
+ actor := newFederatingActor(f, f, db.Federation(), clock)
+ f.actor = actor
+ return f
+}
+
+func (f *federator) FederatingActor() pub.FederatingActor {
+ return f.actor
+}
diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go
@@ -0,0 +1,190 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package federation_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ProtocolTestSuite struct {
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ storage storage.Storage
+ typeConverter typeutils.TypeConverter
+ accounts map[string]*gtsmodel.Account
+ activities map[string]testrig.ActivityWithSignature
+}
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *ProtocolTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.storage = testrig.NewTestStorage()
+ suite.typeConverter = testrig.NewTestTypeConverter(suite.db)
+ suite.accounts = testrig.NewTestAccounts()
+ suite.activities = testrig.NewTestActivities(suite.accounts)
+}
+
+func (suite *ProtocolTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *ProtocolTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+}
+
+// make sure PostInboxRequestBodyHook properly sets the inbox username and activity on the context
+func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() {
+
+ // the activity we're gonna use
+ activity := suite.activities["dm_for_zork"]
+
+ // setup transport controller with a no-op client so we don't make external calls
+ tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
+ return nil, nil
+ }))
+ // setup module being tested
+ federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter)
+
+ // setup request
+ ctx := context.Background()
+ request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting
+ request.Header.Set("Signature", activity.SignatureHeader)
+
+ // trigger the function being tested, and return the new context it creates
+ newContext, err := federator.PostInboxRequestBodyHook(ctx, request, activity.Activity)
+ assert.NoError(suite.T(), err)
+ assert.NotNil(suite.T(), newContext)
+
+ // activity should be set on context now
+ activityI := newContext.Value(util.APActivity)
+ assert.NotNil(suite.T(), activityI)
+ returnedActivity, ok := activityI.(pub.Activity)
+ assert.True(suite.T(), ok)
+ assert.NotNil(suite.T(), returnedActivity)
+ assert.EqualValues(suite.T(), activity.Activity, returnedActivity)
+}
+
+func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() {
+
+ // the activity we're gonna use
+ activity := suite.activities["dm_for_zork"]
+ sendingAccount := suite.accounts["remote_account_1"]
+ inboxAccount := suite.accounts["local_account_1"]
+
+ encodedPublicKey, err := x509.MarshalPKIXPublicKey(sendingAccount.PublicKey)
+ assert.NoError(suite.T(), err)
+ publicKeyBytes := pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: encodedPublicKey,
+ })
+ publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n")
+
+ // for this test we need the client to return the public key of the activity creator on the 'remote' instance
+ responseBodyString := fmt.Sprintf(`
+ {
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1"
+ ],
+
+ "id": "%s",
+ "type": "Person",
+ "preferredUsername": "%s",
+ "inbox": "%s",
+
+ "publicKey": {
+ "id": "%s",
+ "owner": "%s",
+ "publicKeyPem": "%s"
+ }
+ }`, sendingAccount.URI, sendingAccount.Username, sendingAccount.InboxURI, sendingAccount.PublicKeyURI, sendingAccount.URI, publicKeyString)
+
+ // create a transport controller whose client will just return the response body string we specified above
+ tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
+ r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString)))
+ return &http.Response{
+ StatusCode: 200,
+ Body: r,
+ }, nil
+ }))
+
+ // now setup module being tested, with the mock transport controller
+ federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter)
+
+ // setup request
+ ctx := context.Background()
+ // by the time AuthenticatePostInbox is called, PostInboxRequestBodyHook should have already been called,
+ // which should have set the account and username onto the request. We can replicate that behavior here:
+ ctxWithAccount := context.WithValue(ctx, util.APAccount, inboxAccount)
+ ctxWithActivity := context.WithValue(ctxWithAccount, util.APActivity, activity)
+
+ request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting
+ // we need these headers for the request to be validated
+ request.Header.Set("Signature", activity.SignatureHeader)
+ request.Header.Set("Date", activity.DateHeader)
+ request.Header.Set("Digest", activity.DigestHeader)
+ // we can pass this recorder as a writer and read it back after
+ recorder := httptest.NewRecorder()
+
+ // trigger the function being tested, and return the new context it creates
+ newContext, authed, err := federator.AuthenticatePostInbox(ctxWithActivity, recorder, request)
+ assert.NoError(suite.T(), err)
+ assert.True(suite.T(), authed)
+
+ // since we know this account already it should be set on the context
+ requestingAccountI := newContext.Value(util.APRequestingAccount)
+ assert.NotNil(suite.T(), requestingAccountI)
+ requestingAccount, ok := requestingAccountI.(*gtsmodel.Account)
+ assert.True(suite.T(), ok)
+ assert.Equal(suite.T(), sendingAccount.Username, requestingAccount.Username)
+}
+
+func TestProtocolTestSuite(t *testing.T) {
+ suite.Run(t, new(ProtocolTestSuite))
+}
diff --git a/internal/federation/util.go b/internal/federation/util.go
@@ -0,0 +1,237 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package federation
+
+import (
+ "context"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/go-fed/activity/streams"
+ "github.com/go-fed/activity/streams/vocab"
+ "github.com/go-fed/httpsig"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+/*
+ publicKeyer is BORROWED DIRECTLY FROM https://github.com/go-fed/apcore/blob/master/ap/util.go
+ Thank you @cj@mastodon.technology ! <3
+*/
+type publicKeyer interface {
+ GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
+}
+
+/*
+ getPublicKeyFromResponse is adapted from https://github.com/go-fed/apcore/blob/master/ap/util.go
+ Thank you @cj@mastodon.technology ! <3
+*/
+func getPublicKeyFromResponse(c context.Context, b []byte, keyID *url.URL) (vocab.W3IDSecurityV1PublicKey, error) {
+ m := make(map[string]interface{})
+ if err := json.Unmarshal(b, &m); err != nil {
+ return nil, err
+ }
+
+ t, err := streams.ToType(c, m)
+ if err != nil {
+ return nil, err
+ }
+
+ pker, ok := t.(publicKeyer)
+ if !ok {
+ return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %T", t)
+ }
+
+ pkp := pker.GetW3IDSecurityV1PublicKey()
+ if pkp == nil {
+ return nil, errors.New("publicKey property is not provided")
+ }
+
+ var pkpFound vocab.W3IDSecurityV1PublicKey
+ for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() {
+ if !pkpIter.IsW3IDSecurityV1PublicKey() {
+ continue
+ }
+ pkValue := pkpIter.Get()
+ var pkID *url.URL
+ pkID, err = pub.GetId(pkValue)
+ if err != nil {
+ return nil, err
+ }
+ if pkID.String() != keyID.String() {
+ continue
+ }
+ pkpFound = pkValue
+ break
+ }
+
+ if pkpFound == nil {
+ return nil, fmt.Errorf("cannot find publicKey with id: %s", keyID)
+ }
+
+ return pkpFound, nil
+}
+
+// AuthenticateFederatedRequest authenticates any kind of incoming federated request from a remote server. This includes things like
+// GET requests for dereferencing our users or statuses etc, and POST requests for delivering new Activities. The function returns
+// the URL of the owner of the public key used in the http signature.
+//
+// Authenticate in this case is defined as just making sure that the http request is actually signed by whoever claims
+// to have signed it, by fetching the public key from the signature and checking it against the remote public key. This function
+// *does not* check whether the request is authorized, only whether it's authentic.
+//
+// The provided username will be used to generate a transport for making remote requests/derefencing the public key ID of the request signature.
+// Ideally you should pass in the username of the user *being requested*, so that the remote server can decide how to handle the request based on who's making it.
+// Ie., if the request on this server is for https://example.org/users/some_username then you should pass in the username 'some_username'.
+// The remote server will then know that this is the user making the dereferencing request, and they can decide to allow or deny the request depending on their settings.
+//
+// Note that it is also valid to pass in an empty string here, in which case the keys of the instance account will be used.
+//
+// Also note that this function *does not* dereference the remote account that the signature key is associated with.
+// Other functions should use the returned URL to dereference the remote account, if required.
+func (f *federator) AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) {
+ verifier, err := httpsig.NewVerifier(r)
+ if err != nil {
+ return nil, fmt.Errorf("could not create http sig verifier: %s", err)
+ }
+
+ // The key ID should be given in the signature so that we know where to fetch it from the remote server.
+ // This will be something like https://example.org/users/whatever_requesting_user#main-key
+ requestingPublicKeyID, err := url.Parse(verifier.KeyId())
+ if err != nil {
+ return nil, fmt.Errorf("could not parse key id into a url: %s", err)
+ }
+
+ transport, err := f.GetTransportForUser(username)
+ if err != nil {
+ return nil, fmt.Errorf("transport err: %s", err)
+ }
+
+ // The actual http call to the remote server is made right here in the Dereference function.
+ b, err := transport.Dereference(context.Background(), requestingPublicKeyID)
+ if err != nil {
+ return nil, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err)
+ }
+
+ // if the key isn't in the response, we can't authenticate the request
+ requestingPublicKey, err := getPublicKeyFromResponse(context.Background(), b, requestingPublicKeyID)
+ if err != nil {
+ return nil, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err)
+ }
+
+ // we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey
+ pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem()
+ if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() {
+ return nil, errors.New("publicKeyPem property is not provided or it is not embedded as a value")
+ }
+
+ // and decode the PEM so that we can parse it as a golang public key
+ pubKeyPem := pkPemProp.Get()
+ block, _ := pem.Decode([]byte(pubKeyPem))
+ if block == nil || block.Type != "PUBLIC KEY" {
+ return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type")
+ }
+
+ p, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("could not parse public key from block bytes: %s", err)
+ }
+ if p == nil {
+ return nil, errors.New("returned public key was empty")
+ }
+
+ // do the actual authentication here!
+ algo := httpsig.RSA_SHA256 // TODO: make this more robust
+ if err := verifier.Verify(p, algo); err != nil {
+ return nil, fmt.Errorf("error verifying key %s: %s", requestingPublicKeyID.String(), err)
+ }
+
+ // all good! we just need the URI of the key owner to return
+ pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner()
+ if pkOwnerProp == nil || !pkOwnerProp.IsIRI() {
+ return nil, errors.New("publicKeyOwner property is not provided or it is not embedded as a value")
+ }
+ pkOwnerURI := pkOwnerProp.GetIRI()
+
+ return pkOwnerURI, nil
+}
+
+func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) {
+
+ transport, err := f.GetTransportForUser(username)
+ if err != nil {
+ return nil, fmt.Errorf("transport err: %s", err)
+ }
+
+ b, err := transport.Dereference(context.Background(), remoteAccountID)
+ if err != nil {
+ return nil, fmt.Errorf("error deferencing %s: %s", remoteAccountID.String(), err)
+ }
+
+ m := make(map[string]interface{})
+ if err := json.Unmarshal(b, &m); err != nil {
+ return nil, fmt.Errorf("error unmarshalling bytes into json: %s", err)
+ }
+
+ t, err := streams.ToType(context.Background(), m)
+ if err != nil {
+ return nil, fmt.Errorf("error resolving json into ap vocab type: %s", err)
+ }
+
+ switch t.GetTypeName() {
+ case string(gtsmodel.ActivityStreamsPerson):
+ p, ok := t.(vocab.ActivityStreamsPerson)
+ if !ok {
+ return nil, errors.New("error resolving type as activitystreams person")
+ }
+ return p, nil
+ case string(gtsmodel.ActivityStreamsApplication):
+ // TODO: convert application into person
+ }
+
+ return nil, fmt.Errorf("type name %s not supported", t.GetTypeName())
+}
+
+func (f *federator) GetTransportForUser(username string) (pub.Transport, error) {
+ // We need an account to use to create a transport for dereferecing the signature.
+ // If a username has been given, we can fetch the account with that username and use it.
+ // Otherwise, we can take the instance account and use those credentials to make the request.
+ ourAccount := >smodel.Account{}
+ var u string
+ if username == "" {
+ u = f.config.Host
+ } else {
+ u = username
+ }
+ if err := f.db.GetLocalAccountByUsername(u, ourAccount); err != nil {
+ return nil, fmt.Errorf("error getting account %s from db: %s", username, err)
+ }
+
+ transport, err := f.transportController.NewTransport(ourAccount.PublicKeyURI, ourAccount.PrivateKey)
+ if err != nil {
+ return nil, fmt.Errorf("error creating transport for user %s: %s", username, err)
+ }
+ return transport, nil
+}
diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go
@@ -21,36 +21,37 @@ package gotosocial
import (
"context"
"fmt"
+ "net/http"
"os"
"os/signal"
"syscall"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/action"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/admin"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/app"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/auth"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
- mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/security"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
- "github.com/superseriousbusiness/gotosocial/internal/cache"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/app"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/auth"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"
+ mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/security"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/distributor"
"github.com/superseriousbusiness/gotosocial/internal/federation"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
"github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/router"
"github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/transport"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
// Run creates and starts a gotosocial server
var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logrus.Logger) error {
- dbService, err := db.New(ctx, c, log)
+ dbService, err := db.NewPostgresService(ctx, c, log)
if err != nil {
return fmt.Errorf("error creating dbservice: %s", err)
}
@@ -65,28 +66,30 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
return fmt.Errorf("error creating storage backend: %s", err)
}
+ // build converters and util
+ typeConverter := typeutils.NewConverter(c, dbService)
+
// build backend handlers
mediaHandler := media.New(c, dbService, storageBackend, log)
oauthServer := oauth.New(dbService, log)
- distributor := distributor.New(log)
- if err := distributor.Start(); err != nil {
- return fmt.Errorf("error starting distributor: %s", err)
+ transportController := transport.NewController(c, &federation.Clock{}, http.DefaultClient, log)
+ federator := federation.NewFederator(dbService, transportController, c, log, typeConverter)
+ processor := message.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storageBackend, dbService, log)
+ if err := processor.Start(); err != nil {
+ return fmt.Errorf("error starting processor: %s", err)
}
- // build converters and util
- mastoConverter := mastotypes.New(c, dbService)
-
// build client api modules
- authModule := auth.New(oauthServer, dbService, log)
- accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log)
- appsModule := app.New(oauthServer, dbService, mastoConverter, log)
- mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log)
- fileServerModule := fileserver.New(c, dbService, storageBackend, log)
- adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log)
- statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log)
+ authModule := auth.New(c, dbService, oauthServer, log)
+ accountModule := account.New(c, processor, log)
+ appsModule := app.New(c, processor, log)
+ mm := mediaModule.New(c, processor, log)
+ fileServerModule := fileserver.New(c, processor, log)
+ adminModule := admin.New(c, processor, log)
+ statusModule := status.New(c, processor, log)
securityModule := security.New(c, log)
- apiModules := []apimodule.ClientAPIModule{
+ apis := []api.ClientModule{
// modules with middleware go first
securityModule,
authModule,
@@ -100,20 +103,17 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
statusModule,
}
- for _, m := range apiModules {
+ for _, m := range apis {
if err := m.Route(router); err != nil {
return fmt.Errorf("routing error: %s", err)
}
- if err := m.CreateTables(dbService); err != nil {
- return fmt.Errorf("table creation error: %s", err)
- }
}
if err := dbService.CreateInstanceAccount(); err != nil {
return fmt.Errorf("error creating instance account: %s", err)
}
- gts, err := New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c)
+ gts, err := New(dbService, router, federator, c)
if err != nil {
return fmt.Errorf("error creating gotosocial service: %s", err)
}
diff --git a/internal/gotosocial/gotosocial.go b/internal/gotosocial/gotosocial.go
@@ -21,10 +21,9 @@ package gotosocial
import (
"context"
- "github.com/go-fed/activity/pub"
- "github.com/superseriousbusiness/gotosocial/internal/cache"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
@@ -38,23 +37,21 @@ type Gotosocial interface {
// New returns a new gotosocial server, initialized with the given configuration.
// An error will be returned the caller if something goes wrong during initialization
// eg., no db or storage connection, port for router already in use, etc.
-func New(db db.DB, cache cache.Cache, apiRouter router.Router, federationAPI pub.FederatingActor, config *config.Config) (Gotosocial, error) {
+func New(db db.DB, apiRouter router.Router, federator federation.Federator, config *config.Config) (Gotosocial, error) {
return &gotosocial{
- db: db,
- cache: cache,
- apiRouter: apiRouter,
- federationAPI: federationAPI,
- config: config,
+ db: db,
+ apiRouter: apiRouter,
+ federator: federator,
+ config: config,
}, nil
}
// gotosocial fulfils the gotosocial interface.
type gotosocial struct {
- db db.DB
- cache cache.Cache
- apiRouter router.Router
- federationAPI pub.FederatingActor
- config *config.Config
+ db db.DB
+ apiRouter router.Router
+ federator federation.Federator
+ config *config.Config
}
// Start starts up the gotosocial server. If something goes wrong
diff --git a/internal/gotosocial/mock_Gotosocial.go b/internal/gotosocial/mock_Gotosocial.go
@@ -1,42 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package gotosocial
-
-import (
- context "context"
-
- mock "github.com/stretchr/testify/mock"
-)
-
-// MockGotosocial is an autogenerated mock type for the Gotosocial type
-type MockGotosocial struct {
- mock.Mock
-}
-
-// Start provides a mock function with given fields: _a0
-func (_m *MockGotosocial) Start(_a0 context.Context) error {
- ret := _m.Called(_a0)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context) error); ok {
- r0 = rf(_a0)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// Stop provides a mock function with given fields: _a0
-func (_m *MockGotosocial) Stop(_a0 context.Context) error {
- ret := _m.Called(_a0)
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context) error); ok {
- r0 = rf(_a0)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
diff --git a/internal/db/gtsmodel/README.md b/internal/gtsmodel/README.md
diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go
@@ -0,0 +1,148 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// Package gtsmodel contains types used *internally* by GoToSocial and added/removed/selected from the database.
+// These types should never be serialized and/or sent out via public APIs, as they contain sensitive information.
+// The annotation used on these structs is for handling them via the go-pg ORM (hence why they're in this db subdir).
+// See here for more info on go-pg model annotations: https://pg.uptrace.dev/models/
+package gtsmodel
+
+import (
+ "crypto/rsa"
+ "time"
+)
+
+// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc)
+type Account struct {
+ /*
+ BASIC INFO
+ */
+
+ // id of this account in the local database; the end-user will never need to know this, it's strictly internal
+ ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
+ // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org``
+ Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other
+ // Domain of the account, will be null if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username.
+ Domain string `pg:",unique:userdomain"` // username and domain should be unique *with* each other
+
+ /*
+ ACCOUNT METADATA
+ */
+
+ // ID of the avatar as a media attachment
+ AvatarMediaAttachmentID string
+ // For a non-local account, where can the header be fetched?
+ AvatarRemoteURL string
+ // ID of the header as a media attachment
+ HeaderMediaAttachmentID string
+ // For a non-local account, where can the header be fetched?
+ HeaderRemoteURL string
+ // DisplayName for this account. Can be empty, then just the Username will be used for display purposes.
+ DisplayName string
+ // a key/value map of fields that this account has added to their profile
+ Fields []Field
+ // A note that this account has on their profile (ie., the account's bio/description of themselves)
+ Note string
+ // Is this a memorial account, ie., has the user passed away?
+ Memorial bool
+ // This account has moved this account id in the database
+ MovedToAccountID string
+ // When was this account created?
+ CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+ // When was this account last updated?
+ UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+ // Does this account identify itself as a bot?
+ Bot bool
+ // What reason was given for signing up when this account was created?
+ Reason string
+
+ /*
+ USER AND PRIVACY PREFERENCES
+ */
+
+ // Does this account need an approval for new followers?
+ Locked bool
+ // Should this account be shown in the instance's profile directory?
+ Discoverable bool
+ // Default post privacy for this account
+ Privacy Visibility
+ // Set posts from this account to sensitive by default?
+ Sensitive bool
+ // What language does this account post in?
+ Language string
+
+ /*
+ ACTIVITYPUB THINGS
+ */
+
+ // What is the activitypub URI for this account discovered by webfinger?
+ URI string `pg:",unique"`
+ // At which URL can we see the user account in a web browser?
+ URL string `pg:",unique"`
+ // Last time this account was located using the webfinger API.
+ LastWebfingeredAt time.Time `pg:"type:timestamp"`
+ // Address of this account's activitypub inbox, for sending activity to
+ InboxURI string `pg:",unique"`
+ // Address of this account's activitypub outbox
+ OutboxURI string `pg:",unique"`
+ // URI for getting the following list of this account
+ FollowingURI string `pg:",unique"`
+ // URI for getting the followers list of this account
+ FollowersURI string `pg:",unique"`
+ // URL for getting the featured collection list of this account
+ FeaturedCollectionURI string `pg:",unique"`
+ // What type of activitypub actor is this account?
+ ActorType ActivityStreamsActor
+ // This account is associated with x account id
+ AlsoKnownAs string
+
+ /*
+ CRYPTO FIELDS
+ */
+
+ // Privatekey for validating activitypub requests, will obviously only be defined for local accounts
+ PrivateKey *rsa.PrivateKey
+ // Publickey for encoding activitypub requests, will be defined for both local and remote accounts
+ PublicKey *rsa.PublicKey
+ // Web-reachable location of this account's public key
+ PublicKeyURI string
+
+ /*
+ ADMIN FIELDS
+ */
+
+ // When was this account set to have all its media shown as sensitive?
+ SensitizedAt time.Time `pg:"type:timestamp"`
+ // When was this account silenced (eg., statuses only visible to followers, not public)?
+ SilencedAt time.Time `pg:"type:timestamp"`
+ // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account)
+ SuspendedAt time.Time `pg:"type:timestamp"`
+ // Should we hide this account's collections?
+ HideCollections bool
+ // id of the user that suspended this account through an admin action
+ SuspensionOrigin string
+}
+
+// Field represents a key value field on an account, for things like pronouns, website, etc.
+// VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the
+// username of the user.
+type Field struct {
+ Name string
+ Value string
+ VerifiedAt time.Time `pg:"type:timestamp"`
+}
diff --git a/internal/db/gtsmodel/activitystreams.go b/internal/gtsmodel/activitystreams.go
diff --git a/internal/db/gtsmodel/application.go b/internal/gtsmodel/application.go
diff --git a/internal/db/gtsmodel/block.go b/internal/gtsmodel/block.go
diff --git a/internal/db/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go
diff --git a/internal/db/gtsmodel/emaildomainblock.go b/internal/gtsmodel/emaildomainblock.go
diff --git a/internal/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go
@@ -0,0 +1,77 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package gtsmodel
+
+import "time"
+
+// Emoji represents a custom emoji that's been uploaded through the admin UI, and is useable by instance denizens.
+type Emoji struct {
+ // database ID of this emoji
+ ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"`
+ // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_
+ // eg., 'blob_hug' 'purple_heart' Must be unique with domain.
+ Shortcode string `pg:",notnull,unique:shortcodedomain"`
+ // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
+ Domain string `pg:",notnull,default:'',use_zero,unique:shortcodedomain"`
+ // When was this emoji created. Must be unique with shortcode.
+ CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+ // When was this emoji updated
+ UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+ // Where can this emoji be retrieved remotely? Null for local emojis.
+ // For remote emojis, it'll be something like:
+ // https://hackers.town/system/custom_emojis/images/000/049/842/original/1b74481204feabfd.png
+ ImageRemoteURL string
+ // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
+ // For remote emojis, it'll be something like:
+ // https://hackers.town/system/custom_emojis/images/000/049/842/static/1b74481204feabfd.png
+ ImageStaticRemoteURL string
+ // Where can this emoji be retrieved from the local server? Null for remote emojis.
+ // Assuming our server is hosted at 'example.org', this will be something like:
+ // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
+ ImageURL string
+ // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
+ // Assuming our server is hosted at 'example.org', this will be something like:
+ // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
+ ImageStaticURL string
+ // Path of the emoji image in the server storage system. Will be something like:
+ // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
+ ImagePath string `pg:",notnull"`
+ // Path of a static version of the emoji image in the server storage system. Will be something like:
+ // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
+ ImageStaticPath string `pg:",notnull"`
+ // MIME content type of the emoji image
+ // Probably "image/png"
+ ImageContentType string `pg:",notnull"`
+ // MIME content type of the static version of the emoji image.
+ ImageStaticContentType string `pg:",notnull"`
+ // Size of the emoji image file in bytes, for serving purposes.
+ ImageFileSize int `pg:",notnull"`
+ // Size of the static version of the emoji image file in bytes, for serving purposes.
+ ImageStaticFileSize int `pg:",notnull"`
+ // When was the emoji image last updated?
+ ImageUpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+ // Has a moderation action disabled this emoji from being shown?
+ Disabled bool `pg:",notnull,default:false"`
+ // ActivityStreams uri of this emoji. Something like 'https://example.org/emojis/1234'
+ URI string `pg:",notnull,unique"`
+ // Is this emoji visible in the admin emoji picker?
+ VisibleInPicker bool `pg:",notnull,default:true"`
+ // In which emoji category is this emoji visible?
+ CategoryID string
+}
diff --git a/internal/db/gtsmodel/follow.go b/internal/gtsmodel/follow.go
diff --git a/internal/db/gtsmodel/followrequest.go b/internal/gtsmodel/followrequest.go
diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go
@@ -0,0 +1,150 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package gtsmodel
+
+import (
+ "time"
+)
+
+// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is
+// somewhere in storage and that can be retrieved and served by the router.
+type MediaAttachment struct {
+ // ID of the attachment in the database
+ ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
+ // ID of the status to which this is attached
+ StatusID string
+ // Where can the attachment be retrieved on *this* server
+ URL string
+ // Where can the attachment be retrieved on a remote server (empty for local media)
+ RemoteURL string
+ // When was the attachment created
+ CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+ // When was the attachment last updated
+ UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+ // Type of file (image/gif/audio/video)
+ Type FileType `pg:",notnull"`
+ // Metadata about the file
+ FileMeta FileMeta
+ // To which account does this attachment belong
+ AccountID string `pg:",notnull"`
+ // Description of the attachment (for screenreaders)
+ Description string
+ // To which scheduled status does this attachment belong
+ ScheduledStatusID string
+ // What is the generated blurhash of this attachment
+ Blurhash string
+ // What is the processing status of this attachment
+ Processing ProcessingStatus
+ // metadata for the whole file
+ File File
+ // small image thumbnail derived from a larger image, video, or audio file.
+ Thumbnail Thumbnail
+ // Is this attachment being used as an avatar?
+ Avatar bool
+ // Is this attachment being used as a header?
+ Header bool
+}
+
+// File refers to the metadata for the whole file
+type File struct {
+ // What is the path of the file in storage.
+ Path string
+ // What is the MIME content type of the file.
+ ContentType string
+ // What is the size of the file in bytes.
+ FileSize int
+ // When was the file last updated.
+ UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+}
+
+// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file.
+type Thumbnail struct {
+ // What is the path of the file in storage
+ Path string
+ // What is the MIME content type of the file.
+ ContentType string
+ // What is the size of the file in bytes
+ FileSize int
+ // When was the file last updated
+ UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
+ // What is the URL of the thumbnail on the local server
+ URL string
+ // What is the remote URL of the thumbnail (empty for local media)
+ RemoteURL string
+}
+
+// ProcessingStatus refers to how far along in the processing stage the attachment is.
+type ProcessingStatus int
+
+const (
+ // ProcessingStatusReceived indicates the attachment has been received and is awaiting processing. No thumbnail available yet.
+ ProcessingStatusReceived ProcessingStatus = 0
+ // ProcessingStatusProcessing indicates the attachment is currently being processed. Thumbnail is available but full media is not.
+ ProcessingStatusProcessing ProcessingStatus = 1
+ // ProcessingStatusProcessed indicates the attachment has been fully processed and is ready to be served.
+ ProcessingStatusProcessed ProcessingStatus = 2
+ // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted.
+ ProcessingStatusError ProcessingStatus = 666
+)
+
+// FileType refers to the file type of the media attaachment.
+type FileType string
+
+const (
+ // FileTypeImage is for jpegs and pngs
+ FileTypeImage FileType = "Image"
+ // FileTypeGif is for native gifs and soundless videos that have been converted to gifs
+ FileTypeGif FileType = "Gif"
+ // FileTypeAudio is for audio-only files (no video)
+ FileTypeAudio FileType = "Audio"
+ // FileTypeVideo is for files with audio + visual
+ FileTypeVideo FileType = "Video"
+ // FileTypeUnknown is for unknown file types (surprise surprise!)
+ FileTypeUnknown FileType = "Unknown"
+)
+
+// FileMeta describes metadata about the actual contents of the file.
+type FileMeta struct {
+ Original Original
+ Small Small
+ Focus Focus
+}
+
+// Small can be used for a thumbnail of any media type
+type Small struct {
+ Width int
+ Height int
+ Size int
+ Aspect float64
+}
+
+// Original can be used for original metadata for any media type
+type Original struct {
+ Width int
+ Height int
+ Size int
+ Aspect float64
+}
+
+// Focus describes the 'center' of the image for display purposes.
+// X and Y should each be between -1 and 1
+type Focus struct {
+ X float32
+ Y float32
+}
diff --git a/internal/db/gtsmodel/mention.go b/internal/gtsmodel/mention.go
diff --git a/internal/db/gtsmodel/poll.go b/internal/gtsmodel/poll.go
diff --git a/internal/db/gtsmodel/status.go b/internal/gtsmodel/status.go
diff --git a/internal/db/gtsmodel/statusbookmark.go b/internal/gtsmodel/statusbookmark.go
diff --git a/internal/db/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go
diff --git a/internal/db/gtsmodel/statusmute.go b/internal/gtsmodel/statusmute.go
diff --git a/internal/db/gtsmodel/statuspin.go b/internal/gtsmodel/statuspin.go
diff --git a/internal/db/gtsmodel/tag.go b/internal/gtsmodel/tag.go
diff --git a/internal/db/gtsmodel/user.go b/internal/gtsmodel/user.go
diff --git a/internal/mastotypes/converter.go b/internal/mastotypes/converter.go
@@ -1,544 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-import (
- "fmt"
- "time"
-
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
- "github.com/superseriousbusiness/gotosocial/internal/util"
-)
-
-// Converter is an interface for the common action of converting between mastotypes (frontend, serializable) models and internal gts models used in the database.
-// It requires access to the database because many of the conversions require pulling out database entries and counting them etc.
-type Converter interface {
- // AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error
- // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields,
- // so serve it only to an authorized user who should have permission to see it.
- AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error)
-
- // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error
- // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields.
- // In other words, this is the public record that the server has of an account.
- AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error)
-
- // AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error
- // if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields
- // (such as client id and client secret), so serve it only to an authorized user who should have permission to see it.
- AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error)
-
- // AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error
- // if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive
- // fields sanitized so that it can be served to non-authorized accounts without revealing any private information.
- AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error)
-
- // AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API.
- AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error)
-
- // MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API.
- MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error)
-
- // EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API.
- EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error)
-
- // TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API.
- TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error)
-
- // StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API.
- StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error)
-}
-
-type converter struct {
- config *config.Config
- db db.DB
-}
-
-// New returns a new Converter
-func New(config *config.Config, db db.DB) Converter {
- return &converter{
- config: config,
- db: db,
- }
-}
-
-func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Account, error) {
- // we can build this sensitive account easily by first getting the public account....
- mastoAccount, err := c.AccountToMastoPublic(a)
- if err != nil {
- return nil, err
- }
-
- // then adding the Source object to it...
-
- // check pending follow requests aimed at this account
- fr := []gtsmodel.FollowRequest{}
- if err := c.db.GetFollowRequestsForAccountID(a.ID, &fr); err != nil {
- if _, ok := err.(db.ErrNoEntries); !ok {
- return nil, fmt.Errorf("error getting follow requests: %s", err)
- }
- }
- var frc int
- if fr != nil {
- frc = len(fr)
- }
-
- mastoAccount.Source = &mastotypes.Source{
- Privacy: util.ParseMastoVisFromGTSVis(a.Privacy),
- Sensitive: a.Sensitive,
- Language: a.Language,
- Note: a.Note,
- Fields: mastoAccount.Fields,
- FollowRequestsCount: frc,
- }
-
- return mastoAccount, nil
-}
-
-func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) {
- // count followers
- followers := []gtsmodel.Follow{}
- if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil {
- if _, ok := err.(db.ErrNoEntries); !ok {
- return nil, fmt.Errorf("error getting followers: %s", err)
- }
- }
- var followersCount int
- if followers != nil {
- followersCount = len(followers)
- }
-
- // count following
- following := []gtsmodel.Follow{}
- if err := c.db.GetFollowingByAccountID(a.ID, &following); err != nil {
- if _, ok := err.(db.ErrNoEntries); !ok {
- return nil, fmt.Errorf("error getting following: %s", err)
- }
- }
- var followingCount int
- if following != nil {
- followingCount = len(following)
- }
-
- // count statuses
- statuses := []gtsmodel.Status{}
- if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil {
- if _, ok := err.(db.ErrNoEntries); !ok {
- return nil, fmt.Errorf("error getting last statuses: %s", err)
- }
- }
- var statusesCount int
- if statuses != nil {
- statusesCount = len(statuses)
- }
-
- // check when the last status was
- lastStatus := >smodel.Status{}
- if err := c.db.GetLastStatusForAccountID(a.ID, lastStatus); err != nil {
- if _, ok := err.(db.ErrNoEntries); !ok {
- return nil, fmt.Errorf("error getting last status: %s", err)
- }
- }
- var lastStatusAt string
- if lastStatus != nil {
- lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339)
- }
-
- // build the avatar and header URLs
- avi := >smodel.MediaAttachment{}
- if err := c.db.GetAvatarForAccountID(avi, a.ID); err != nil {
- if _, ok := err.(db.ErrNoEntries); !ok {
- return nil, fmt.Errorf("error getting avatar: %s", err)
- }
- }
- aviURL := avi.URL
- aviURLStatic := avi.Thumbnail.URL
-
- header := >smodel.MediaAttachment{}
- if err := c.db.GetHeaderForAccountID(avi, a.ID); err != nil {
- if _, ok := err.(db.ErrNoEntries); !ok {
- return nil, fmt.Errorf("error getting header: %s", err)
- }
- }
- headerURL := header.URL
- headerURLStatic := header.Thumbnail.URL
-
- // get the fields set on this account
- fields := []mastotypes.Field{}
- for _, f := range a.Fields {
- mField := mastotypes.Field{
- Name: f.Name,
- Value: f.Value,
- }
- if !f.VerifiedAt.IsZero() {
- mField.VerifiedAt = f.VerifiedAt.Format(time.RFC3339)
- }
- fields = append(fields, mField)
- }
-
- var acct string
- if a.Domain != "" {
- // this is a remote user
- acct = fmt.Sprintf("%s@%s", a.Username, a.Domain)
- } else {
- // this is a local user
- acct = a.Username
- }
-
- return &mastotypes.Account{
- ID: a.ID,
- Username: a.Username,
- Acct: acct,
- DisplayName: a.DisplayName,
- Locked: a.Locked,
- Bot: a.Bot,
- CreatedAt: a.CreatedAt.Format(time.RFC3339),
- Note: a.Note,
- URL: a.URL,
- Avatar: aviURL,
- AvatarStatic: aviURLStatic,
- Header: headerURL,
- HeaderStatic: headerURLStatic,
- FollowersCount: followersCount,
- FollowingCount: followingCount,
- StatusesCount: statusesCount,
- LastStatusAt: lastStatusAt,
- Emojis: nil, // TODO: implement this
- Fields: fields,
- }, nil
-}
-
-func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Application, error) {
- return &mastotypes.Application{
- ID: a.ID,
- Name: a.Name,
- Website: a.Website,
- RedirectURI: a.RedirectURI,
- ClientID: a.ClientID,
- ClientSecret: a.ClientSecret,
- VapidKey: a.VapidKey,
- }, nil
-}
-
-func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*mastotypes.Application, error) {
- return &mastotypes.Application{
- Name: a.Name,
- Website: a.Website,
- }, nil
-}
-
-func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) {
- return mastotypes.Attachment{
- ID: a.ID,
- Type: string(a.Type),
- URL: a.URL,
- PreviewURL: a.Thumbnail.URL,
- RemoteURL: a.RemoteURL,
- PreviewRemoteURL: a.Thumbnail.RemoteURL,
- Meta: mastotypes.MediaMeta{
- Original: mastotypes.MediaDimensions{
- Width: a.FileMeta.Original.Width,
- Height: a.FileMeta.Original.Height,
- Size: fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height),
- Aspect: float32(a.FileMeta.Original.Aspect),
- },
- Small: mastotypes.MediaDimensions{
- Width: a.FileMeta.Small.Width,
- Height: a.FileMeta.Small.Height,
- Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height),
- Aspect: float32(a.FileMeta.Small.Aspect),
- },
- Focus: mastotypes.MediaFocus{
- X: a.FileMeta.Focus.X,
- Y: a.FileMeta.Focus.Y,
- },
- },
- Description: a.Description,
- Blurhash: a.Blurhash,
- }, nil
-}
-
-func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) {
- target := >smodel.Account{}
- if err := c.db.GetByID(m.TargetAccountID, target); err != nil {
- return mastotypes.Mention{}, err
- }
-
- var local bool
- if target.Domain == "" {
- local = true
- }
-
- var acct string
- if local {
- acct = fmt.Sprintf("@%s", target.Username)
- } else {
- acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain)
- }
-
- return mastotypes.Mention{
- ID: target.ID,
- Username: target.Username,
- URL: target.URL,
- Acct: acct,
- }, nil
-}
-
-func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) {
- return mastotypes.Emoji{
- Shortcode: e.Shortcode,
- URL: e.ImageURL,
- StaticURL: e.ImageStaticURL,
- VisibleInPicker: e.VisibleInPicker,
- Category: e.CategoryID,
- }, nil
-}
-
-func (c *converter) TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) {
- tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name)
-
- return mastotypes.Tag{
- Name: t.Name,
- URL: tagURL, // we don't serve URLs with collections of tagged statuses (FOR NOW) so this is purely for mastodon compatibility ¯\_(ツ)_/¯
- }, nil
-}
-
-func (c *converter) StatusToMasto(
- s *gtsmodel.Status,
- targetAccount *gtsmodel.Account,
- requestingAccount *gtsmodel.Account,
- boostOfAccount *gtsmodel.Account,
- replyToAccount *gtsmodel.Account,
- reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) {
-
- repliesCount, err := c.db.GetReplyCountForStatus(s)
- if err != nil {
- return nil, fmt.Errorf("error counting replies: %s", err)
- }
-
- reblogsCount, err := c.db.GetReblogCountForStatus(s)
- if err != nil {
- return nil, fmt.Errorf("error counting reblogs: %s", err)
- }
-
- favesCount, err := c.db.GetFaveCountForStatus(s)
- if err != nil {
- return nil, fmt.Errorf("error counting faves: %s", err)
- }
-
- var faved bool
- var reblogged bool
- var bookmarked bool
- var pinned bool
- var muted bool
-
- // requestingAccount will be nil for public requests without auth
- // But if it's not nil, we can also get information about the requestingAccount's interaction with this status
- if requestingAccount != nil {
- faved, err = c.db.StatusFavedBy(s, requestingAccount.ID)
- if err != nil {
- return nil, fmt.Errorf("error checking if requesting account has faved status: %s", err)
- }
-
- reblogged, err = c.db.StatusRebloggedBy(s, requestingAccount.ID)
- if err != nil {
- return nil, fmt.Errorf("error checking if requesting account has reblogged status: %s", err)
- }
-
- muted, err = c.db.StatusMutedBy(s, requestingAccount.ID)
- if err != nil {
- return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err)
- }
-
- bookmarked, err = c.db.StatusBookmarkedBy(s, requestingAccount.ID)
- if err != nil {
- return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err)
- }
-
- pinned, err = c.db.StatusPinnedBy(s, requestingAccount.ID)
- if err != nil {
- return nil, fmt.Errorf("error checking if requesting account has pinned status: %s", err)
- }
- }
-
- var mastoRebloggedStatus *mastotypes.Status // TODO
-
- var mastoApplication *mastotypes.Application
- if s.CreatedWithApplicationID != "" {
- gtsApplication := >smodel.Application{}
- if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil {
- return nil, fmt.Errorf("error fetching application used to create status: %s", err)
- }
- mastoApplication, err = c.AppToMastoPublic(gtsApplication)
- if err != nil {
- return nil, fmt.Errorf("error parsing application used to create status: %s", err)
- }
- }
-
- mastoTargetAccount, err := c.AccountToMastoPublic(targetAccount)
- if err != nil {
- return nil, fmt.Errorf("error parsing account of status author: %s", err)
- }
-
- mastoAttachments := []mastotypes.Attachment{}
- // the status might already have some gts attachments on it if it's not been pulled directly from the database
- // if so, we can directly convert the gts attachments into masto ones
- if s.GTSMediaAttachments != nil {
- for _, gtsAttachment := range s.GTSMediaAttachments {
- mastoAttachment, err := c.AttachmentToMasto(gtsAttachment)
- if err != nil {
- return nil, fmt.Errorf("error converting attachment with id %s: %s", gtsAttachment.ID, err)
- }
- mastoAttachments = append(mastoAttachments, mastoAttachment)
- }
- // the status doesn't have gts attachments on it, but it does have attachment IDs
- // in this case, we need to pull the gts attachments from the db to convert them into masto ones
- } else {
- for _, a := range s.Attachments {
- gtsAttachment := >smodel.MediaAttachment{}
- if err := c.db.GetByID(a, gtsAttachment); err != nil {
- return nil, fmt.Errorf("error getting attachment with id %s: %s", a, err)
- }
- mastoAttachment, err := c.AttachmentToMasto(gtsAttachment)
- if err != nil {
- return nil, fmt.Errorf("error converting attachment with id %s: %s", a, err)
- }
- mastoAttachments = append(mastoAttachments, mastoAttachment)
- }
- }
-
- mastoMentions := []mastotypes.Mention{}
- // the status might already have some gts mentions on it if it's not been pulled directly from the database
- // if so, we can directly convert the gts mentions into masto ones
- if s.GTSMentions != nil {
- for _, gtsMention := range s.GTSMentions {
- mastoMention, err := c.MentionToMasto(gtsMention)
- if err != nil {
- return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err)
- }
- mastoMentions = append(mastoMentions, mastoMention)
- }
- // the status doesn't have gts mentions on it, but it does have mention IDs
- // in this case, we need to pull the gts mentions from the db to convert them into masto ones
- } else {
- for _, m := range s.Mentions {
- gtsMention := >smodel.Mention{}
- if err := c.db.GetByID(m, gtsMention); err != nil {
- return nil, fmt.Errorf("error getting mention with id %s: %s", m, err)
- }
- mastoMention, err := c.MentionToMasto(gtsMention)
- if err != nil {
- return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err)
- }
- mastoMentions = append(mastoMentions, mastoMention)
- }
- }
-
- mastoTags := []mastotypes.Tag{}
- // the status might already have some gts tags on it if it's not been pulled directly from the database
- // if so, we can directly convert the gts tags into masto ones
- if s.GTSTags != nil {
- for _, gtsTag := range s.GTSTags {
- mastoTag, err := c.TagToMasto(gtsTag)
- if err != nil {
- return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err)
- }
- mastoTags = append(mastoTags, mastoTag)
- }
- // the status doesn't have gts tags on it, but it does have tag IDs
- // in this case, we need to pull the gts tags from the db to convert them into masto ones
- } else {
- for _, t := range s.Tags {
- gtsTag := >smodel.Tag{}
- if err := c.db.GetByID(t, gtsTag); err != nil {
- return nil, fmt.Errorf("error getting tag with id %s: %s", t, err)
- }
- mastoTag, err := c.TagToMasto(gtsTag)
- if err != nil {
- return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err)
- }
- mastoTags = append(mastoTags, mastoTag)
- }
- }
-
- mastoEmojis := []mastotypes.Emoji{}
- // the status might already have some gts emojis on it if it's not been pulled directly from the database
- // if so, we can directly convert the gts emojis into masto ones
- if s.GTSEmojis != nil {
- for _, gtsEmoji := range s.GTSEmojis {
- mastoEmoji, err := c.EmojiToMasto(gtsEmoji)
- if err != nil {
- return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err)
- }
- mastoEmojis = append(mastoEmojis, mastoEmoji)
- }
- // the status doesn't have gts emojis on it, but it does have emoji IDs
- // in this case, we need to pull the gts emojis from the db to convert them into masto ones
- } else {
- for _, e := range s.Emojis {
- gtsEmoji := >smodel.Emoji{}
- if err := c.db.GetByID(e, gtsEmoji); err != nil {
- return nil, fmt.Errorf("error getting emoji with id %s: %s", e, err)
- }
- mastoEmoji, err := c.EmojiToMasto(gtsEmoji)
- if err != nil {
- return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err)
- }
- mastoEmojis = append(mastoEmojis, mastoEmoji)
- }
- }
-
- var mastoCard *mastotypes.Card
- var mastoPoll *mastotypes.Poll
-
- return &mastotypes.Status{
- ID: s.ID,
- CreatedAt: s.CreatedAt.Format(time.RFC3339),
- InReplyToID: s.InReplyToID,
- InReplyToAccountID: s.InReplyToAccountID,
- Sensitive: s.Sensitive,
- SpoilerText: s.ContentWarning,
- Visibility: util.ParseMastoVisFromGTSVis(s.Visibility),
- Language: s.Language,
- URI: s.URI,
- URL: s.URL,
- RepliesCount: repliesCount,
- ReblogsCount: reblogsCount,
- FavouritesCount: favesCount,
- Favourited: faved,
- Reblogged: reblogged,
- Muted: muted,
- Bookmarked: bookmarked,
- Pinned: pinned,
- Content: s.Content,
- Reblog: mastoRebloggedStatus,
- Application: mastoApplication,
- Account: mastoTargetAccount,
- MediaAttachments: mastoAttachments,
- Mentions: mastoMentions,
- Tags: mastoTags,
- Emojis: mastoEmojis,
- Card: mastoCard, // TODO: implement cards
- Poll: mastoPoll, // TODO: implement polls
- Text: s.Text,
- }, nil
-}
diff --git a/internal/mastotypes/mastomodel/README.md b/internal/mastotypes/mastomodel/README.md
@@ -1,5 +0,0 @@
-# Mastotypes
-
-This package contains Go types/structs for Mastodon's REST API.
-
-See [here](https://docs.joinmastodon.org/methods/apps/).
diff --git a/internal/mastotypes/mastomodel/account.go b/internal/mastotypes/mastomodel/account.go
@@ -1,131 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-import "mime/multipart"
-
-// Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/
-type Account struct {
- // The account id
- ID string `json:"id"`
- // The username of the account, not including domain.
- Username string `json:"username"`
- // The Webfinger account URI. Equal to username for local users, or username@domain for remote users.
- Acct string `json:"acct"`
- // The profile's display name.
- DisplayName string `json:"display_name"`
- // Whether the account manually approves follow requests.
- Locked bool `json:"locked"`
- // Whether the account has opted into discovery features such as the profile directory.
- Discoverable bool `json:"discoverable,omitempty"`
- // A presentational flag. Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot.
- Bot bool `json:"bot"`
- // When the account was created. (ISO 8601 Datetime)
- CreatedAt string `json:"created_at"`
- // The profile's bio / description.
- Note string `json:"note"`
- // The location of the user's profile page.
- URL string `json:"url"`
- // An image icon that is shown next to statuses and in the profile.
- Avatar string `json:"avatar"`
- // A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF.
- AvatarStatic string `json:"avatar_static"`
- // An image banner that is shown above the profile and in profile cards.
- Header string `json:"header"`
- // A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF.
- HeaderStatic string `json:"header_static"`
- // The reported followers of this profile.
- FollowersCount int `json:"followers_count"`
- // The reported follows of this profile.
- FollowingCount int `json:"following_count"`
- // How many statuses are attached to this account.
- StatusesCount int `json:"statuses_count"`
- // When the most recent status was posted. (ISO 8601 Datetime)
- LastStatusAt string `json:"last_status_at"`
- // Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned.
- Emojis []Emoji `json:"emojis"`
- // Additional metadata attached to a profile as name-value pairs.
- Fields []Field `json:"fields"`
- // An extra entity returned when an account is suspended.
- Suspended bool `json:"suspended,omitempty"`
- // When a timed mute will expire, if applicable. (ISO 8601 Datetime)
- MuteExpiresAt string `json:"mute_expires_at,omitempty"`
- // An extra entity to be used with API methods to verify credentials and update credentials.
- Source *Source `json:"source,omitempty"`
-}
-
-// AccountCreateRequest represents the form submitted during a POST request to /api/v1/accounts.
-// See https://docs.joinmastodon.org/methods/accounts/
-type AccountCreateRequest struct {
- // Text that will be reviewed by moderators if registrations require manual approval.
- Reason string `form:"reason"`
- // The desired username for the account
- Username string `form:"username" binding:"required"`
- // The email address to be used for login
- Email string `form:"email" binding:"required"`
- // The password to be used for login
- Password string `form:"password" binding:"required"`
- // Whether the user agrees to the local rules, terms, and policies.
- // These should be presented to the user in order to allow them to consent before setting this parameter to TRUE.
- Agreement bool `form:"agreement" binding:"required"`
- // The language of the confirmation email that will be sent
- Locale string `form:"locale" binding:"required"`
-}
-
-// UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials.
-// See https://docs.joinmastodon.org/methods/accounts/
-type UpdateCredentialsRequest struct {
- // Whether the account should be shown in the profile directory.
- Discoverable *bool `form:"discoverable"`
- // Whether the account has a bot flag.
- Bot *bool `form:"bot"`
- // The display name to use for the profile.
- DisplayName *string `form:"display_name"`
- // The account bio.
- Note *string `form:"note"`
- // Avatar image encoded using multipart/form-data
- Avatar *multipart.FileHeader `form:"avatar"`
- // Header image encoded using multipart/form-data
- Header *multipart.FileHeader `form:"header"`
- // Whether manual approval of follow requests is required.
- Locked *bool `form:"locked"`
- // New Source values for this account
- Source *UpdateSource `form:"source"`
- // Profile metadata name and value
- FieldsAttributes *[]UpdateField `form:"fields_attributes"`
-}
-
-// UpdateSource is to be used specifically in an UpdateCredentialsRequest.
-type UpdateSource struct {
- // Default post privacy for authored statuses.
- Privacy *string `form:"privacy"`
- // Whether to mark authored statuses as sensitive by default.
- Sensitive *bool `form:"sensitive"`
- // Default language to use for authored statuses. (ISO 6391)
- Language *string `form:"language"`
-}
-
-// UpdateField is to be used specifically in an UpdateCredentialsRequest.
-// By default, max 4 fields and 255 characters per property/value.
-type UpdateField struct {
- // Name of the field
- Name *string `form:"name"`
- // Value of the field
- Value *string `form:"value"`
-}
diff --git a/internal/mastotypes/mastomodel/activity.go b/internal/mastotypes/mastomodel/activity.go
@@ -1,31 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Activity represents the mastodon-api Activity type. See here: https://docs.joinmastodon.org/entities/activity/
-type Activity struct {
- // Midnight at the first day of the week. (UNIX Timestamp as string)
- Week string `json:"week"`
- // Statuses created since the week began. Integer cast to string.
- Statuses string `json:"statuses"`
- // User logins since the week began. Integer cast as string.
- Logins string `json:"logins"`
- // User registrations since the week began. Integer cast as string.
- Registrations string `json:"registrations"`
-}
diff --git a/internal/mastotypes/mastomodel/admin.go b/internal/mastotypes/mastomodel/admin.go
@@ -1,81 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// AdminAccountInfo represents the *admin* view of an account's details. See here: https://docs.joinmastodon.org/entities/admin-account/
-type AdminAccountInfo struct {
- // The ID of the account in the database.
- ID string `json:"id"`
- // The username of the account.
- Username string `json:"username"`
- // The domain of the account.
- Domain string `json:"domain"`
- // When the account was first discovered. (ISO 8601 Datetime)
- CreatedAt string `json:"created_at"`
- // The email address associated with the account.
- Email string `json:"email"`
- // The IP address last used to login to this account.
- IP string `json:"ip"`
- // The locale of the account. (ISO 639 Part 1 two-letter language code)
- Locale string `json:"locale"`
- // Invite request text
- InviteRequest string `json:"invite_request"`
- // The current role of the account.
- Role string `json:"role"`
- // Whether the account has confirmed their email address.
- Confirmed bool `json:"confirmed"`
- // Whether the account is currently approved.
- Approved bool `json:"approved"`
- // Whether the account is currently disabled.
- Disabled bool `json:"disabled"`
- // Whether the account is currently silenced
- Silenced bool `json:"silenced"`
- // Whether the account is currently suspended.
- Suspended bool `json:"suspended"`
- // User-level information about the account.
- Account *Account `json:"account"`
- // The ID of the application that created this account.
- CreatedByApplicationID string `json:"created_by_application_id,omitempty"`
- // The ID of the account that invited this user
- InvitedByAccountID string `json:"invited_by_account_id"`
-}
-
-// AdminReportInfo represents the *admin* view of a report. See here: https://docs.joinmastodon.org/entities/admin-report/
-type AdminReportInfo struct {
- // The ID of the report in the database.
- ID string `json:"id"`
- // The action taken to resolve this report.
- ActionTaken string `json:"action_taken"`
- // An optional reason for reporting.
- Comment string `json:"comment"`
- // The time the report was filed. (ISO 8601 Datetime)
- CreatedAt string `json:"created_at"`
- // The time of last action on this report. (ISO 8601 Datetime)
- UpdatedAt string `json:"updated_at"`
- // The account which filed the report.
- Account *Account `json:"account"`
- // The account being reported.
- TargetAccount *Account `json:"target_account"`
- // The account of the moderator assigned to this report.
- AssignedAccount *Account `json:"assigned_account"`
- // The action taken by the moderator who handled the report.
- ActionTakenByAccount string `json:"action_taken_by_account"`
- // Statuses attached to the report, for context.
- Statuses []Status `json:"statuses"`
-}
diff --git a/internal/mastotypes/mastomodel/announcement.go b/internal/mastotypes/mastomodel/announcement.go
@@ -1,37 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Announcement represents an admin/moderator announcement for local users. See here: https://docs.joinmastodon.org/entities/announcement/
-type Announcement struct {
- ID string `json:"id"`
- Content string `json:"content"`
- StartsAt string `json:"starts_at"`
- EndsAt string `json:"ends_at"`
- AllDay bool `json:"all_day"`
- PublishedAt string `json:"published_at"`
- UpdatedAt string `json:"updated_at"`
- Published bool `json:"published"`
- Read bool `json:"read"`
- Mentions []Mention `json:"mentions"`
- Statuses []Status `json:"statuses"`
- Tags []Tag `json:"tags"`
- Emojis []Emoji `json:"emoji"`
- Reactions []AnnouncementReaction `json:"reactions"`
-}
diff --git a/internal/mastotypes/mastomodel/announcementreaction.go b/internal/mastotypes/mastomodel/announcementreaction.go
@@ -1,33 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// AnnouncementReaction represents a user reaction to admin/moderator announcement. See here: https://docs.joinmastodon.org/entities/announcementreaction/
-type AnnouncementReaction struct {
- // The emoji used for the reaction. Either a unicode emoji, or a custom emoji's shortcode.
- Name string `json:"name"`
- // The total number of users who have added this reaction.
- Count int `json:"count"`
- // Whether the authorized user has added this reaction to the announcement.
- Me bool `json:"me"`
- // A link to the custom emoji.
- URL string `json:"url,omitempty"`
- // A link to a non-animated version of the custom emoji.
- StaticURL string `json:"static_url,omitempty"`
-}
diff --git a/internal/mastotypes/mastomodel/application.go b/internal/mastotypes/mastomodel/application.go
@@ -1,55 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/.
-// Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user.
-// See https://docs.joinmastodon.org/methods/apps/
-type Application struct {
- // The application ID in the db
- ID string `json:"id,omitempty"`
- // The name of your application.
- Name string `json:"name"`
- // The website associated with your application (url)
- Website string `json:"website,omitempty"`
- // Where the user should be redirected after authorization.
- RedirectURI string `json:"redirect_uri,omitempty"`
- // ClientID to use when obtaining an oauth token for this application (ie., in client_id parameter of https://docs.joinmastodon.org/methods/apps/)
- ClientID string `json:"client_id,omitempty"`
- // Client secret to use when obtaining an auth token for this application (ie., in client_secret parameter of https://docs.joinmastodon.org/methods/apps/)
- ClientSecret string `json:"client_secret,omitempty"`
- // Used for Push Streaming API. Returned with POST /api/v1/apps. Equivalent to https://docs.joinmastodon.org/entities/pushsubscription/#server_key
- VapidKey string `json:"vapid_key,omitempty"`
-}
-
-// ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps.
-// See here: https://docs.joinmastodon.org/methods/apps/
-// And here: https://docs.joinmastodon.org/client/token/
-type ApplicationPOSTRequest struct {
- // A name for your application
- ClientName string `form:"client_name" binding:"required"`
- // Where the user should be redirected after authorization.
- // To display the authorization code to the user instead of redirecting
- // to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter.
- RedirectURIs string `form:"redirect_uris" binding:"required"`
- // Space separated list of scopes. If none is provided, defaults to read.
- Scopes string `form:"scopes"`
- // A URL to the homepage of your app
- Website string `form:"website"`
-}
diff --git a/internal/mastotypes/mastomodel/attachment.go b/internal/mastotypes/mastomodel/attachment.go
@@ -1,98 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-import "mime/multipart"
-
-// AttachmentRequest represents the form data parameters submitted by a client during a media upload request.
-// See: https://docs.joinmastodon.org/methods/statuses/media/
-type AttachmentRequest struct {
- File *multipart.FileHeader `form:"file"`
- Thumbnail *multipart.FileHeader `form:"thumbnail"`
- Description string `form:"description"`
- Focus string `form:"focus"`
-}
-
-// Attachment represents the object returned to a client after a successful media upload request.
-// See: https://docs.joinmastodon.org/methods/statuses/media/
-type Attachment struct {
- // The ID of the attachment in the database.
- ID string `json:"id"`
- // The type of the attachment.
- // unknown = unsupported or unrecognized file type.
- // image = Static image.
- // gifv = Looping, soundless animation.
- // video = Video clip.
- // audio = Audio track.
- Type string `json:"type"`
- // The location of the original full-size attachment.
- URL string `json:"url"`
- // The location of a scaled-down preview of the attachment.
- PreviewURL string `json:"preview_url"`
- // The location of the full-size original attachment on the remote server.
- RemoteURL string `json:"remote_url,omitempty"`
- // The location of a scaled-down preview of the attachment on the remote server.
- PreviewRemoteURL string `json:"preview_remote_url,omitempty"`
- // A shorter URL for the attachment.
- TextURL string `json:"text_url,omitempty"`
- // Metadata returned by Paperclip.
- // May contain subtrees small and original, as well as various other top-level properties.
- // More importantly, there may be another top-level focus Hash object as of 2.3.0, with coordinates can be used for smart thumbnail cropping.
- // See https://docs.joinmastodon.org/methods/statuses/media/#focal-points points for more.
- Meta MediaMeta `json:"meta,omitempty"`
- // Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load.
- Description string `json:"description,omitempty"`
- // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.
- // See https://github.com/woltapp/blurhash
- Blurhash string `json:"blurhash,omitempty"`
-}
-
-// MediaMeta describes the returned media
-type MediaMeta struct {
- Length string `json:"length,omitempty"`
- Duration float32 `json:"duration,omitempty"`
- FPS uint16 `json:"fps,omitempty"`
- Size string `json:"size,omitempty"`
- Width int `json:"width,omitempty"`
- Height int `json:"height,omitempty"`
- Aspect float32 `json:"aspect,omitempty"`
- AudioEncode string `json:"audio_encode,omitempty"`
- AudioBitrate string `json:"audio_bitrate,omitempty"`
- AudioChannels string `json:"audio_channels,omitempty"`
- Original MediaDimensions `json:"original"`
- Small MediaDimensions `json:"small,omitempty"`
- Focus MediaFocus `json:"focus,omitempty"`
-}
-
-// MediaFocus describes the focal point of a piece of media. It should be returned to the caller as part of MediaMeta.
-type MediaFocus struct {
- X float32 `json:"x"` // should be between -1 and 1
- Y float32 `json:"y"` // should be between -1 and 1
-}
-
-// MediaDimensions describes the physical properties of a piece of media. It should be returned to the caller as part of MediaMeta.
-type MediaDimensions struct {
- Width int `json:"width,omitempty"`
- Height int `json:"height,omitempty"`
- FrameRate string `json:"frame_rate,omitempty"`
- Duration float32 `json:"duration,omitempty"`
- Bitrate int `json:"bitrate,omitempty"`
- Size string `json:"size,omitempty"`
- Aspect float32 `json:"aspect,omitempty"`
-}
diff --git a/internal/mastotypes/mastomodel/card.go b/internal/mastotypes/mastomodel/card.go
@@ -1,61 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Card represents a rich preview card that is generated using OpenGraph tags from a URL. See here: https://docs.joinmastodon.org/entities/card/
-type Card struct {
- // REQUIRED
-
- // Location of linked resource.
- URL string `json:"url"`
- // Title of linked resource.
- Title string `json:"title"`
- // Description of preview.
- Description string `json:"description"`
- // The type of the preview card.
- // String (Enumerable, oneOf)
- // link = Link OEmbed
- // photo = Photo OEmbed
- // video = Video OEmbed
- // rich = iframe OEmbed. Not currently accepted, so won't show up in practice.
- Type string `json:"type"`
-
- // OPTIONAL
-
- // The author of the original resource.
- AuthorName string `json:"author_name"`
- // A link to the author of the original resource.
- AuthorURL string `json:"author_url"`
- // The provider of the original resource.
- ProviderName string `json:"provider_name"`
- // A link to the provider of the original resource.
- ProviderURL string `json:"provider_url"`
- // HTML to be used for generating the preview card.
- HTML string `json:"html"`
- // Width of preview, in pixels.
- Width int `json:"width"`
- // Height of preview, in pixels.
- Height int `json:"height"`
- // Preview thumbnail.
- Image string `json:"image"`
- // Used for photo embeds, instead of custom html.
- EmbedURL string `json:"embed_url"`
- // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet.
- Blurhash string `json:"blurhash"`
-}
diff --git a/internal/mastotypes/mastomodel/context.go b/internal/mastotypes/mastomodel/context.go
@@ -1,27 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Context represents the tree around a given status. Used for reconstructing threads of statuses. See: https://docs.joinmastodon.org/entities/context/
-type Context struct {
- // Parents in the thread.
- Ancestors []Status `json:"ancestors"`
- // Children in the thread.
- Descendants []Status `json:"descendants"`
-}
diff --git a/internal/mastotypes/mastomodel/conversation.go b/internal/mastotypes/mastomodel/conversation.go
@@ -1,36 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Conversation represents a conversation with "direct message" visibility. See https://docs.joinmastodon.org/entities/conversation/
-type Conversation struct {
- // REQUIRED
-
- // Local database ID of the conversation.
- ID string `json:"id"`
- // Participants in the conversation.
- Accounts []Account `json:"accounts"`
- // Is the conversation currently marked as unread?
- Unread bool `json:"unread"`
-
- // OPTIONAL
-
- // The last status in the conversation, to be used for optional display.
- LastStatus *Status `json:"last_status"`
-}
diff --git a/internal/mastotypes/mastomodel/emoji.go b/internal/mastotypes/mastomodel/emoji.go
@@ -1,48 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-import "mime/multipart"
-
-// Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/
-type Emoji struct {
- // REQUIRED
-
- // The name of the custom emoji.
- Shortcode string `json:"shortcode"`
- // A link to the custom emoji.
- URL string `json:"url"`
- // A link to a static copy of the custom emoji.
- StaticURL string `json:"static_url"`
- // Whether this Emoji should be visible in the picker or unlisted.
- VisibleInPicker bool `json:"visible_in_picker"`
-
- // OPTIONAL
-
- // Used for sorting custom emoji in the picker.
- Category string `json:"category,omitempty"`
-}
-
-// 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"`
- // Image file to use for the emoji. Must be png or gif and no larger than 50kb.
- Image *multipart.FileHeader `form:"image" validation:"required"`
-}
diff --git a/internal/mastotypes/mastomodel/error.go b/internal/mastotypes/mastomodel/error.go
@@ -1,32 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Error represents an error message returned from the API. See https://docs.joinmastodon.org/entities/error/
-type Error struct {
- // REQUIRED
-
- // The error message.
- Error string `json:"error"`
-
- // OPTIONAL
-
- // A longer description of the error, mainly provided with the OAuth API.
- ErrorDescription string `json:"error_description"`
-}
diff --git a/internal/mastotypes/mastomodel/featuredtag.go b/internal/mastotypes/mastomodel/featuredtag.go
@@ -1,33 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// FeaturedTag represents a hashtag that is featured on a profile. See https://docs.joinmastodon.org/entities/featuredtag/
-type FeaturedTag struct {
- // The internal ID of the featured tag in the database.
- ID string `json:"id"`
- // The name of the hashtag being featured.
- Name string `json:"name"`
- // A link to all statuses by a user that contain this hashtag.
- URL string `json:"url"`
- // The number of authored statuses containing this hashtag.
- StatusesCount int `json:"statuses_count"`
- // The timestamp of the last authored status containing this hashtag. (ISO 8601 Datetime)
- LastStatusAt string `json:"last_status_at"`
-}
diff --git a/internal/mastotypes/mastomodel/field.go b/internal/mastotypes/mastomodel/field.go
@@ -1,33 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Field represents a profile field as a name-value pair with optional verification. See https://docs.joinmastodon.org/entities/field/
-type Field struct {
- // REQUIRED
-
- // The key of a given field's key-value pair.
- Name string `json:"name"`
- // The value associated with the name key.
- Value string `json:"value"`
-
- // OPTIONAL
- // Timestamp of when the server verified a URL value for a rel="me” link. String (ISO 8601 Datetime) if value is a verified URL
- VerifiedAt string `json:"verified_at,omitempty"`
-}
diff --git a/internal/mastotypes/mastomodel/filter.go b/internal/mastotypes/mastomodel/filter.go
@@ -1,46 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Filter represents a user-defined filter for determining which statuses should not be shown to the user. See https://docs.joinmastodon.org/entities/filter/
-// If whole_word is true , client app should do:
-// Define ‘word constituent character’ for your app. In the official implementation, it’s [A-Za-z0-9_] in JavaScript, and [[:word:]] in Ruby.
-// Ruby uses the POSIX character class (Letter | Mark | Decimal_Number | Connector_Punctuation).
-// If the phrase starts with a word character, and if the previous character before matched range is a word character, its matched range should be treated to not match.
-// If the phrase ends with a word character, and if the next character after matched range is a word character, its matched range should be treated to not match.
-// Please check app/javascript/mastodon/selectors/index.js and app/lib/feed_manager.rb in the Mastodon source code for more details.
-type Filter struct {
- // The ID of the filter in the database.
- ID string `json:"id"`
- // The text to be filtered.
- Phrase string `json:"text"`
- // The contexts in which the filter should be applied.
- // Array of String (Enumerable anyOf)
- // home = home timeline and lists
- // notifications = notifications timeline
- // public = public timelines
- // thread = expanded thread of a detailed status
- Context []string `json:"context"`
- // Should the filter consider word boundaries?
- WholeWord bool `json:"whole_word"`
- // When the filter should no longer be applied (ISO 8601 Datetime), or null if the filter does not expire
- ExpiresAt string `json:"expires_at,omitempty"`
- // Should matching entities in home and notifications be dropped by the server?
- Irreversible bool `json:"irreversible"`
-}
diff --git a/internal/mastotypes/mastomodel/history.go b/internal/mastotypes/mastomodel/history.go
@@ -1,29 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// History represents daily usage history of a hashtag. See https://docs.joinmastodon.org/entities/history/
-type History struct {
- // UNIX timestamp on midnight of the given day (string cast from integer).
- Day string `json:"day"`
- // The counted usage of the tag within that day (string cast from integer).
- Uses string `json:"uses"`
- // The total of accounts using the tag within that day (string cast from integer).
- Accounts string `json:"accounts"`
-}
diff --git a/internal/mastotypes/mastomodel/identityproof.go b/internal/mastotypes/mastomodel/identityproof.go
@@ -1,33 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// IdentityProof represents a proof from an external identity provider. See https://docs.joinmastodon.org/entities/identityproof/
-type IdentityProof struct {
- // The name of the identity provider.
- Provider string `json:"provider"`
- // The account owner's username on the identity provider's service.
- ProviderUsername string `json:"provider_username"`
- // The account owner's profile URL on the identity provider.
- ProfileURL string `json:"profile_url"`
- // A link to a statement of identity proof, hosted by the identity provider.
- ProofURL string `json:"proof_url"`
- // When the identity proof was last updated.
- UpdatedAt string `json:"updated_at"`
-}
diff --git a/internal/mastotypes/mastomodel/instance.go b/internal/mastotypes/mastomodel/instance.go
@@ -1,72 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/
-type Instance struct {
- // REQUIRED
-
- // The domain name of the instance.
- URI string `json:"uri"`
- // The title of the website.
- Title string `json:"title"`
- // Admin-defined description of the Mastodon site.
- Description string `json:"description"`
- // A shorter description defined by the admin.
- ShortDescription string `json:"short_description"`
- // An email that may be contacted for any inquiries.
- Email string `json:"email"`
- // The version of Mastodon installed on the instance.
- Version string `json:"version"`
- // Primary langauges of the website and its staff.
- Languages []string `json:"languages"`
- // Whether registrations are enabled.
- Registrations bool `json:"registrations"`
- // Whether registrations require moderator approval.
- ApprovalRequired bool `json:"approval_required"`
- // Whether invites are enabled.
- InvitesEnabled bool `json:"invites_enabled"`
- // URLs of interest for clients apps.
- URLS *InstanceURLs `json:"urls"`
- // Statistics about how much information the instance contains.
- Stats *InstanceStats `json:"stats"`
-
- // OPTIONAL
-
- // Banner image for the website.
- Thumbnail string `json:"thumbnail,omitempty"`
- // A user that can be contacted, as an alternative to email.
- ContactAccount *Account `json:"contact_account,omitempty"`
-}
-
-// InstanceURLs represents URLs necessary for successfully connecting to the instance as a user. See https://docs.joinmastodon.org/entities/instance/
-type InstanceURLs struct {
- // Websockets address for push streaming.
- StreamingAPI string `json:"streaming_api"`
-}
-
-// InstanceStats represents some public-facing stats about the instance. See https://docs.joinmastodon.org/entities/instance/
-type InstanceStats struct {
- // Users registered on this instance.
- UserCount int `json:"user_count"`
- // Statuses authored by users on instance.
- StatusCount int `json:"status_count"`
- // Domains federated with this instance.
- DomainCount int `json:"domain_count"`
-}
diff --git a/internal/mastotypes/mastomodel/list.go b/internal/mastotypes/mastomodel/list.go
@@ -1,31 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// List represents a list of some users that the authenticated user follows. See https://docs.joinmastodon.org/entities/list/
-type List struct {
- // The internal database ID of the list.
- ID string `json:"id"`
- // The user-defined title of the list.
- Title string `json:"title"`
- // followed = Show replies to any followed user
- // list = Show replies to members of the list
- // none = Show replies to no one
- RepliesPolicy string `json:"replies_policy"`
-}
diff --git a/internal/mastotypes/mastomodel/marker.go b/internal/mastotypes/mastomodel/marker.go
@@ -1,37 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Marker represents the last read position within a user's timelines. See https://docs.joinmastodon.org/entities/marker/
-type Marker struct {
- // Information about the user's position in the home timeline.
- Home *TimelineMarker `json:"home"`
- // Information about the user's position in their notifications.
- Notifications *TimelineMarker `json:"notifications"`
-}
-
-// TimelineMarker contains information about a user's progress through a specific timeline. See https://docs.joinmastodon.org/entities/marker/
-type TimelineMarker struct {
- // The ID of the most recently viewed entity.
- LastReadID string `json:"last_read_id"`
- // The timestamp of when the marker was set (ISO 8601 Datetime)
- UpdatedAt string `json:"updated_at"`
- // Used for locking to prevent write conflicts.
- Version string `json:"version"`
-}
diff --git a/internal/mastotypes/mastomodel/mention.go b/internal/mastotypes/mastomodel/mention.go
@@ -1,31 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Mention represents the mastodon-api mention type, as documented here: https://docs.joinmastodon.org/entities/mention/
-type Mention struct {
- // The account id of the mentioned user.
- ID string `json:"id"`
- // The username of the mentioned user.
- Username string `json:"username"`
- // The location of the mentioned user's profile.
- URL string `json:"url"`
- // The webfinger acct: URI of the mentioned user. Equivalent to username for local users, or username@domain for remote users.
- Acct string `json:"acct"`
-}
diff --git a/internal/mastotypes/mastomodel/notification.go b/internal/mastotypes/mastomodel/notification.go
@@ -1,45 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Notification represents a notification of an event relevant to the user. See https://docs.joinmastodon.org/entities/notification/
-type Notification struct {
- // REQUIRED
-
- // The id of the notification in the database.
- ID string `json:"id"`
- // The type of event that resulted in the notification.
- // follow = Someone followed you
- // follow_request = Someone requested to follow you
- // mention = Someone mentioned you in their status
- // reblog = Someone boosted one of your statuses
- // favourite = Someone favourited one of your statuses
- // poll = A poll you have voted in or created has ended
- // status = Someone you enabled notifications for has posted a status
- Type string `json:"type"`
- // The timestamp of the notification (ISO 8601 Datetime)
- CreatedAt string `json:"created_at"`
- // The account that performed the action that generated the notification.
- Account *Account `json:"account"`
-
- // OPTIONAL
-
- // Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.
- Status *Status `json:"status"`
-}
diff --git a/internal/mastotypes/mastomodel/oauth.go b/internal/mastotypes/mastomodel/oauth.go
@@ -1,37 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// OAuthAuthorize represents a request sent to https://example.org/oauth/authorize
-// See here: https://docs.joinmastodon.org/methods/apps/oauth/
-type OAuthAuthorize struct {
- // Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance.
- ForceLogin string `form:"force_login,omitempty"`
- // Should be set equal to `code`.
- ResponseType string `form:"response_type"`
- // Client ID, obtained during app registration.
- ClientID string `form:"client_id"`
- // Set a URI to redirect the user to.
- // If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead.
- // Must match one of the redirect URIs declared during app registration.
- RedirectURI string `form:"redirect_uri"`
- // List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters).
- // Must be a subset of scopes declared during app registration. If not provided, defaults to read.
- Scope string `form:"scope,omitempty"`
-}
diff --git a/internal/mastotypes/mastomodel/poll.go b/internal/mastotypes/mastomodel/poll.go
@@ -1,64 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Poll represents the mastodon-api poll type, as described here: https://docs.joinmastodon.org/entities/poll/
-type Poll struct {
- // The ID of the poll in the database.
- ID string `json:"id"`
- // When the poll ends. (ISO 8601 Datetime), or null if the poll does not end
- ExpiresAt string `json:"expires_at"`
- // Is the poll currently expired?
- Expired bool `json:"expired"`
- // Does the poll allow multiple-choice answers?
- Multiple bool `json:"multiple"`
- // How many votes have been received.
- VotesCount int `json:"votes_count"`
- // How many unique accounts have voted on a multiple-choice poll. Null if multiple is false.
- VotersCount int `json:"voters_count,omitempty"`
- // When called with a user token, has the authorized user voted?
- Voted bool `json:"voted,omitempty"`
- // When called with a user token, which options has the authorized user chosen? Contains an array of index values for options.
- OwnVotes []int `json:"own_votes,omitempty"`
- // Possible answers for the poll.
- Options []PollOptions `json:"options"`
- // Custom emoji to be used for rendering poll options.
- Emojis []Emoji `json:"emojis"`
-}
-
-// PollOptions represents the current vote counts for different poll options
-type PollOptions struct {
- // The text value of the poll option. String.
- Title string `json:"title"`
- // The number of received votes for this option. Number, or null if results are not published yet.
- VotesCount int `json:"votes_count,omitempty"`
-}
-
-// PollRequest represents a mastodon-api poll attached to a status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/
-// It should be used at the path https://example.org/api/v1/statuses
-type PollRequest struct {
- // Array of possible answers. If provided, media_ids cannot be used, and poll[expires_in] must be provided.
- Options []string `form:"options"`
- // Duration the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided.
- ExpiresIn int `form:"expires_in"`
- // Allow multiple choices?
- Multiple bool `form:"multiple"`
- // Hide vote counts until the poll ends?
- HideTotals bool `form:"hide_totals"`
-}
diff --git a/internal/mastotypes/mastomodel/preferences.go b/internal/mastotypes/mastomodel/preferences.go
@@ -1,40 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Preferences represents a user's preferences. See https://docs.joinmastodon.org/entities/preferences/
-type Preferences struct {
- // Default visibility for new posts.
- // public = Public post
- // unlisted = Unlisted post
- // private = Followers-only post
- // direct = Direct post
- PostingDefaultVisibility string `json:"posting:default:visibility"`
- // Default sensitivity flag for new posts.
- PostingDefaultSensitive bool `json:"posting:default:sensitive"`
- // Default language for new posts. (ISO 639-1 language two-letter code), or null
- PostingDefaultLanguage string `json:"posting:default:language,omitempty"`
- // Whether media attachments should be automatically displayed or blurred/hidden.
- // default = Hide media marked as sensitive
- // show_all = Always show all media by default, regardless of sensitivity
- // hide_all = Always hide all media by default, regardless of sensitivity
- ReadingExpandMedia string `json:"reading:expand:media"`
- // Whether CWs should be expanded by default.
- ReadingExpandSpoilers bool `json:"reading:expand:spoilers"`
-}
diff --git a/internal/mastotypes/mastomodel/pushsubscription.go b/internal/mastotypes/mastomodel/pushsubscription.go
@@ -1,45 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// PushSubscription represents a subscription to the push streaming server. See https://docs.joinmastodon.org/entities/pushsubscription/
-type PushSubscription struct {
- // The id of the push subscription in the database.
- ID string `json:"id"`
- // Where push alerts will be sent to.
- Endpoint string `json:"endpoint"`
- // The streaming server's VAPID key.
- ServerKey string `json:"server_key"`
- // Which alerts should be delivered to the endpoint.
- Alerts *PushSubscriptionAlerts `json:"alerts"`
-}
-
-// PushSubscriptionAlerts represents the specific alerts that this push subscription will give.
-type PushSubscriptionAlerts struct {
- // Receive a push notification when someone has followed you?
- Follow bool `json:"follow"`
- // Receive a push notification when a status you created has been favourited by someone else?
- Favourite bool `json:"favourite"`
- // Receive a push notification when someone else has mentioned you in a status?
- Mention bool `json:"mention"`
- // Receive a push notification when a status you created has been boosted by someone else?
- Reblog bool `json:"reblog"`
- // Receive a push notification when a poll you voted in or created has ended?
- Poll bool `json:"poll"`
-}
diff --git a/internal/mastotypes/mastomodel/relationship.go b/internal/mastotypes/mastomodel/relationship.go
@@ -1,49 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Relationship represents a relationship between accounts. See https://docs.joinmastodon.org/entities/relationship/
-type Relationship struct {
- // The account id.
- ID string `json:"id"`
- // Are you following this user?
- Following bool `json:"following"`
- // Are you receiving this user's boosts in your home timeline?
- ShowingReblogs bool `json:"showing_reblogs"`
- // Have you enabled notifications for this user?
- Notifying bool `json:"notifying"`
- // Are you followed by this user?
- FollowedBy bool `json:"followed_by"`
- // Are you blocking this user?
- Blocking bool `json:"blocking"`
- // Is this user blocking you?
- BlockedBy bool `json:"blocked_by"`
- // Are you muting this user?
- Muting bool `json:"muting"`
- // Are you muting notifications from this user?
- MutingNotifications bool `json:"muting_notifications"`
- // Do you have a pending follow request for this user?
- Requested bool `json:"requested"`
- // Are you blocking this user's domain?
- DomainBlocking bool `json:"domain_blocking"`
- // Are you featuring this user on your profile?
- Endorsed bool `json:"endorsed"`
- // Your note on this account.
- Note string `json:"note"`
-}
diff --git a/internal/mastotypes/mastomodel/results.go b/internal/mastotypes/mastomodel/results.go
@@ -1,29 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Results represents the results of a search. See https://docs.joinmastodon.org/entities/results/
-type Results struct {
- // Accounts which match the given query
- Accounts []Account `json:"accounts"`
- // Statuses which match the given query
- Statuses []Status `json:"statuses"`
- // Hashtags which match the given query
- Hashtags []Tag `json:"hashtags"`
-}
diff --git a/internal/mastotypes/mastomodel/scheduledstatus.go b/internal/mastotypes/mastomodel/scheduledstatus.go
@@ -1,39 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// ScheduledStatus represents a status that will be published at a future scheduled date. See https://docs.joinmastodon.org/entities/scheduledstatus/
-type ScheduledStatus struct {
- ID string `json:"id"`
- ScheduledAt string `json:"scheduled_at"`
- Params *StatusParams `json:"params"`
- MediaAttachments []Attachment `json:"media_attachments"`
-}
-
-// StatusParams represents parameters for a scheduled status. See https://docs.joinmastodon.org/entities/scheduledstatus/
-type StatusParams struct {
- Text string `json:"text"`
- InReplyToID string `json:"in_reply_to_id,omitempty"`
- MediaIDs []string `json:"media_ids,omitempty"`
- Sensitive bool `json:"sensitive,omitempty"`
- SpoilerText string `json:"spoiler_text,omitempty"`
- Visibility string `json:"visibility"`
- ScheduledAt string `json:"scheduled_at,omitempty"`
- ApplicationID string `json:"application_id"`
-}
diff --git a/internal/mastotypes/mastomodel/source.go b/internal/mastotypes/mastomodel/source.go
@@ -1,41 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Source represents display or publishing preferences of user's own account.
-// Returned as an additional entity when verifying and updated credentials, as an attribute of Account.
-// See https://docs.joinmastodon.org/entities/source/
-type Source struct {
- // The default post privacy to be used for new statuses.
- // public = Public post
- // unlisted = Unlisted post
- // private = Followers-only post
- // direct = Direct post
- Privacy Visibility `json:"privacy,omitempty"`
- // Whether new statuses should be marked sensitive by default.
- Sensitive bool `json:"sensitive,omitempty"`
- // The default posting language for new statuses.
- Language string `json:"language,omitempty"`
- // Profile bio.
- Note string `json:"note"`
- // Metadata about the account.
- Fields []Field `json:"fields"`
- // The number of pending follow requests.
- FollowRequestsCount int `json:"follow_requests_count,omitempty"`
-}
diff --git a/internal/mastotypes/mastomodel/status.go b/internal/mastotypes/mastomodel/status.go
@@ -1,120 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/
-type Status struct {
- // ID of the status in the database.
- ID string `json:"id"`
- // The date when this status was created (ISO 8601 Datetime)
- CreatedAt string `json:"created_at"`
- // ID of the status being replied.
- InReplyToID string `json:"in_reply_to_id,omitempty"`
- // ID of the account being replied to.
- InReplyToAccountID string `json:"in_reply_to_account_id,omitempty"`
- // Is this status marked as sensitive content?
- Sensitive bool `json:"sensitive"`
- // Subject or summary line, below which status content is collapsed until expanded.
- SpoilerText string `json:"spoiler_text,omitempty"`
- // Visibility of this status.
- Visibility Visibility `json:"visibility"`
- // Primary language of this status. (ISO 639 Part 1 two-letter language code)
- Language string `json:"language"`
- // URI of the status used for federation.
- URI string `json:"uri"`
- // A link to the status's HTML representation.
- URL string `json:"url"`
- // How many replies this status has received.
- RepliesCount int `json:"replies_count"`
- // How many boosts this status has received.
- ReblogsCount int `json:"reblogs_count"`
- // How many favourites this status has received.
- FavouritesCount int `json:"favourites_count"`
- // Have you favourited this status?
- Favourited bool `json:"favourited"`
- // Have you boosted this status?
- Reblogged bool `json:"reblogged"`
- // Have you muted notifications for this status's conversation?
- Muted bool `json:"muted"`
- // Have you bookmarked this status?
- Bookmarked bool `json:"bookmarked"`
- // Have you pinned this status? Only appears if the status is pinnable.
- Pinned bool `json:"pinned"`
- // HTML-encoded status content.
- Content string `json:"content"`
- // The status being reblogged.
- Reblog *Status `json:"reblog,omitempty"`
- // The application used to post this status.
- Application *Application `json:"application"`
- // The account that authored this status.
- Account *Account `json:"account"`
- // Media that is attached to this status.
- MediaAttachments []Attachment `json:"media_attachments"`
- // Mentions of users within the status content.
- Mentions []Mention `json:"mentions"`
- // Hashtags used within the status content.
- Tags []Tag `json:"tags"`
- // Custom emoji to be used when rendering status content.
- Emojis []Emoji `json:"emojis"`
- // Preview card for links included within status content.
- Card *Card `json:"card"`
- // The poll attached to the status.
- Poll *Poll `json:"poll"`
- // Plain-text source of a status. Returned instead of content when status is deleted,
- // so the user may redraft from the source text without the client having to reverse-engineer
- // the original text from the HTML content.
- Text string `json:"text"`
-}
-
-// StatusCreateRequest represents a mastodon-api status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/
-// It should be used at the path https://mastodon.example/api/v1/statuses
-type StatusCreateRequest struct {
- // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided.
- Status string `form:"status"`
- // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used.
- MediaIDs []string `form:"media_ids"`
- // Poll to include with this status.
- Poll *PollRequest `form:"poll"`
- // ID of the status being replied to, if status is a reply
- InReplyToID string `form:"in_reply_to_id"`
- // Mark status and attached media as sensitive?
- Sensitive bool `form:"sensitive"`
- // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field.
- SpoilerText string `form:"spoiler_text"`
- // Visibility of the posted status. Enumerable oneOf public, unlisted, private, direct.
- Visibility Visibility `form:"visibility"`
- // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future.
- ScheduledAt string `form:"scheduled_at"`
- // ISO 639 language code for this status.
- Language string `form:"language"`
-}
-
-// Visibility denotes the visibility of this status to other users
-type Visibility string
-
-const (
- // VisibilityPublic means visible to everyone
- VisibilityPublic Visibility = "public"
- // VisibilityUnlisted means visible to everyone but only on home timelines or in lists
- VisibilityUnlisted Visibility = "unlisted"
- // VisibilityPrivate means visible to followers only
- VisibilityPrivate Visibility = "private"
- // VisibilityDirect means visible only to tagged recipients
- VisibilityDirect Visibility = "direct"
-)
diff --git a/internal/mastotypes/mastomodel/tag.go b/internal/mastotypes/mastomodel/tag.go
@@ -1,27 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package 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"`
-}
diff --git a/internal/mastotypes/mastomodel/token.go b/internal/mastotypes/mastomodel/token.go
@@ -1,31 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package mastotypes
-
-// Token represents an OAuth token used for authenticating with the API and performing actions.. See https://docs.joinmastodon.org/entities/token/
-type Token struct {
- // An OAuth token to be used for authorization.
- AccessToken string `json:"access_token"`
- // The OAuth token type. Mastodon uses Bearer tokens.
- TokenType string `json:"token_type"`
- // The OAuth scopes granted by this token, space-separated.
- Scope string `json:"scope"`
- // When the token was generated. (UNIX timestamp seconds)
- CreatedAt int64 `json:"created_at"`
-}
diff --git a/internal/mastotypes/mock_Converter.go b/internal/mastotypes/mock_Converter.go
@@ -1,148 +0,0 @@
-// Code generated by mockery v2.7.4. DO NOT EDIT.
-
-package mastotypes
-
-import (
- mock "github.com/stretchr/testify/mock"
- gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-)
-
-// MockConverter is an autogenerated mock type for the Converter type
-type MockConverter struct {
- mock.Mock
-}
-
-// AccountToMastoPublic provides a mock function with given fields: account
-func (_m *MockConverter) AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) {
- ret := _m.Called(account)
-
- var r0 *mastotypes.Account
- if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok {
- r0 = rf(account)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*mastotypes.Account)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok {
- r1 = rf(account)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// AccountToMastoSensitive provides a mock function with given fields: account
-func (_m *MockConverter) AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) {
- ret := _m.Called(account)
-
- var r0 *mastotypes.Account
- if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok {
- r0 = rf(account)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*mastotypes.Account)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok {
- r1 = rf(account)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// AppToMastoPublic provides a mock function with given fields: application
-func (_m *MockConverter) AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) {
- ret := _m.Called(application)
-
- var r0 *mastotypes.Application
- if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok {
- r0 = rf(application)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*mastotypes.Application)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok {
- r1 = rf(application)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// AppToMastoSensitive provides a mock function with given fields: application
-func (_m *MockConverter) AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) {
- ret := _m.Called(application)
-
- var r0 *mastotypes.Application
- if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok {
- r0 = rf(application)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*mastotypes.Application)
- }
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok {
- r1 = rf(application)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// AttachmentToMasto provides a mock function with given fields: attachment
-func (_m *MockConverter) AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) {
- ret := _m.Called(attachment)
-
- var r0 mastotypes.Attachment
- if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment) mastotypes.Attachment); ok {
- r0 = rf(attachment)
- } else {
- r0 = ret.Get(0).(mastotypes.Attachment)
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(*gtsmodel.MediaAttachment) error); ok {
- r1 = rf(attachment)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MentionToMasto provides a mock function with given fields: m
-func (_m *MockConverter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) {
- ret := _m.Called(m)
-
- var r0 mastotypes.Mention
- if rf, ok := ret.Get(0).(func(*gtsmodel.Mention) mastotypes.Mention); ok {
- r0 = rf(m)
- } else {
- r0 = ret.Get(0).(mastotypes.Mention)
- }
-
- var r1 error
- if rf, ok := ret.Get(1).(func(*gtsmodel.Mention) error); ok {
- r1 = rf(m)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
diff --git a/internal/media/media.go b/internal/media/media.go
@@ -28,25 +28,32 @@ import (
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/storage"
)
+// Size describes the *size* of a piece of media
+type Size string
+
+// Type describes the *type* of a piece of media
+type Type string
+
const (
- // MediaSmall is the key for small/thumbnail versions of media
- MediaSmall = "small"
- // MediaOriginal is the key for original/fullsize versions of media and emoji
- MediaOriginal = "original"
- // MediaStatic is the key for static (non-animated) versions of emoji
- MediaStatic = "static"
- // MediaAttachment is the key for media attachments
- MediaAttachment = "attachment"
- // MediaHeader is the key for profile header requests
- MediaHeader = "header"
- // MediaAvatar is the key for profile avatar requests
- MediaAvatar = "avatar"
- // MediaEmoji is the key for emoji type requests
- MediaEmoji = "emoji"
+ // Small is the key for small/thumbnail versions of media
+ Small Size = "small"
+ // Original is the key for original/fullsize versions of media and emoji
+ Original Size = "original"
+ // Static is the key for static (non-animated) versions of emoji
+ Static Size = "static"
+
+ // Attachment is the key for media attachments
+ Attachment Type = "attachment"
+ // Header is the key for profile header requests
+ Header Type = "header"
+ // Avatar is the key for profile avatar requests
+ Avatar Type = "avatar"
+ // Emoji is the key for emoji type requests
+ Emoji Type = "emoji"
// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)
EmojiMaxBytes = 51200
@@ -57,7 +64,7 @@ type Handler interface {
// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,
// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,
// and then returns information to the caller about the new header.
- ProcessHeaderOrAvatar(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error)
+ ProcessHeaderOrAvatar(img []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error)
// ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it,
// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media,
@@ -94,10 +101,10 @@ func New(config *config.Config, database db.DB, storage storage.Storage, log *lo
// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,
// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,
// and then returns information to the caller about the new header.
-func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) {
+func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) {
l := mh.log.WithField("func", "SetHeaderForAccountID")
- if headerOrAvi != MediaHeader && headerOrAvi != MediaAvatar {
+ if mediaType != Header && mediaType != Avatar {
return nil, errors.New("header or avatar not selected")
}
@@ -106,7 +113,7 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin
if err != nil {
return nil, err
}
- if !supportedImageType(contentType) {
+ if !SupportedImageType(contentType) {
return nil, fmt.Errorf("%s is not an accepted image type", contentType)
}
@@ -116,14 +123,14 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin
l.Tracef("read %d bytes of file", len(attachment))
// process it
- ma, err := mh.processHeaderOrAvi(attachment, contentType, headerOrAvi, accountID)
+ ma, err := mh.processHeaderOrAvi(attachment, contentType, mediaType, accountID)
if err != nil {
- return nil, fmt.Errorf("error processing %s: %s", headerOrAvi, err)
+ return nil, fmt.Errorf("error processing %s: %s", mediaType, err)
}
// set it in the database
if err := mh.db.SetHeaderOrAvatarForAccountID(ma, accountID); err != nil {
- return nil, fmt.Errorf("error putting %s in database: %s", headerOrAvi, err)
+ return nil, fmt.Errorf("error putting %s in database: %s", mediaType, err)
}
return ma, nil
@@ -139,8 +146,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri
}
mainType := strings.Split(contentType, "/")[0]
switch mainType {
- case "video":
- if !supportedVideoType(contentType) {
+ case MIMEVideo:
+ if !SupportedVideoType(contentType) {
return nil, fmt.Errorf("video type %s not supported", contentType)
}
if len(attachment) == 0 {
@@ -150,8 +157,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri
return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(attachment), mh.config.MediaConfig.MaxVideoSize)
}
return mh.processVideoAttachment(attachment, accountID, contentType)
- case "image":
- if !supportedImageType(contentType) {
+ case MIMEImage:
+ if !SupportedImageType(contentType) {
return nil, fmt.Errorf("image type %s not supported", contentType)
}
if len(attachment) == 0 {
@@ -192,13 +199,13 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (
return nil, fmt.Errorf("emoji size %d bytes exceeded max emoji size of %d bytes", len(emojiBytes), EmojiMaxBytes)
}
- // clean any exif data from image/png type but leave gifs alone
+ // clean any exif data from png but leave gifs alone
switch contentType {
- case "image/png":
+ case MIMEPng:
if clean, err = purgeExif(emojiBytes); err != nil {
return nil, fmt.Errorf("error cleaning exif data: %s", err)
}
- case "image/gif":
+ case MIMEGif:
clean = emojiBytes
default:
return nil, errors.New("media type unrecognized")
@@ -218,7 +225,7 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (
// (ie., fileserver/ACCOUNT_ID/etc etc) we need to fetch the INSTANCE ACCOUNT from the database. That is, the account that's created
// with the same username as the instance hostname, which doesn't belong to any particular user.
instanceAccount := >smodel.Account{}
- if err := mh.db.GetWhere("username", mh.config.Host, instanceAccount); err != nil {
+ if err := mh.db.GetLocalAccountByUsername(mh.config.Host, instanceAccount); err != nil {
return nil, fmt.Errorf("error fetching instance account: %s", err)
}
@@ -234,15 +241,15 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (
// webfinger uri for the emoji -- unrelated to actually serving the image
// will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c
- emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, MediaEmoji, newEmojiID)
+ emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, Emoji, newEmojiID)
// serve url and storage path for the original emoji -- can be png or gif
- emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension)
- emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension)
+ emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, Emoji, Original, newEmojiID, extension)
+ emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Original, newEmojiID, extension)
// serve url and storage path for the static version -- will always be png
- emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID)
- emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID)
+ emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, Emoji, Static, newEmojiID)
+ emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Static, newEmojiID)
// store the original
if err := mh.storage.StoreFileAt(emojiPath, original.image); err != nil {
@@ -256,25 +263,26 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (
// and finally return the new emoji data to the caller -- it's up to them what to do with it
e := >smodel.Emoji{
- ID: newEmojiID,
- Shortcode: shortcode,
- Domain: "", // empty because this is a local emoji
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- ImageRemoteURL: "", // empty because this is a local emoji
- ImageStaticRemoteURL: "", // empty because this is a local emoji
- ImageURL: emojiURL,
- ImageStaticURL: emojiStaticURL,
- ImagePath: emojiPath,
- ImageStaticPath: emojiStaticPath,
- ImageContentType: contentType,
- ImageFileSize: len(original.image),
- ImageStaticFileSize: len(static.image),
- ImageUpdatedAt: time.Now(),
- Disabled: false,
- URI: emojiURI,
- VisibleInPicker: true,
- CategoryID: "", // empty because this is a new emoji -- no category yet
+ ID: newEmojiID,
+ Shortcode: shortcode,
+ Domain: "", // empty because this is a local emoji
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ ImageRemoteURL: "", // empty because this is a local emoji
+ ImageStaticRemoteURL: "", // empty because this is a local emoji
+ ImageURL: emojiURL,
+ ImageStaticURL: emojiStaticURL,
+ ImagePath: emojiPath,
+ ImageStaticPath: emojiStaticPath,
+ ImageContentType: contentType,
+ ImageStaticContentType: MIMEPng, // static version will always be a png
+ ImageFileSize: len(original.image),
+ ImageStaticFileSize: len(static.image),
+ ImageUpdatedAt: time.Now(),
+ Disabled: false,
+ URI: emojiURI,
+ VisibleInPicker: true,
+ CategoryID: "", // empty because this is a new emoji -- no category yet
}
return e, nil
}
@@ -294,7 +302,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
var small *imageAndMeta
switch contentType {
- case "image/jpeg", "image/png":
+ case MIMEJpeg, MIMEPng:
if clean, err = purgeExif(data); err != nil {
return nil, fmt.Errorf("error cleaning exif data: %s", err)
}
@@ -302,7 +310,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
if err != nil {
return nil, fmt.Errorf("error parsing image: %s", err)
}
- case "image/gif":
+ case MIMEGif:
clean = data
original, err = deriveGif(clean, contentType)
if err != nil {
@@ -326,13 +334,13 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.jpeg", URLbase, accountID, newMediaID) // all thumbnails/smalls are encoded as jpeg
// we store the original...
- originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaOriginal, newMediaID, extension)
+ originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, Attachment, Original, newMediaID, extension)
if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {
return nil, fmt.Errorf("storage error: %s", err)
}
// and a thumbnail...
- smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg
+ smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, Attachment, Small, newMediaID) // all thumbnails/smalls are encoded as jpeg
if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
return nil, fmt.Errorf("storage error: %s", err)
}
@@ -372,7 +380,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
},
Thumbnail: gtsmodel.Thumbnail{
Path: smallPath,
- ContentType: "image/jpeg", // all thumbnails/smalls are encoded as jpeg
+ ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg
FileSize: len(small.image),
UpdatedAt: time.Now(),
URL: smallURL,
@@ -386,14 +394,14 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co
}
-func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, headerOrAvi string, accountID string) (*gtsmodel.MediaAttachment, error) {
+func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string) (*gtsmodel.MediaAttachment, error) {
var isHeader bool
var isAvatar bool
- switch headerOrAvi {
- case MediaHeader:
+ switch mediaType {
+ case Header:
isHeader = true
- case MediaAvatar:
+ case Avatar:
isAvatar = true
default:
return nil, errors.New("header or avatar not selected")
@@ -403,15 +411,15 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string
var err error
switch contentType {
- case "image/jpeg":
+ case MIMEJpeg:
if clean, err = purgeExif(imageBytes); err != nil {
return nil, fmt.Errorf("error cleaning exif data: %s", err)
}
- case "image/png":
+ case MIMEPng:
if clean, err = purgeExif(imageBytes); err != nil {
return nil, fmt.Errorf("error cleaning exif data: %s", err)
}
- case "image/gif":
+ case MIMEGif:
clean = imageBytes
default:
return nil, errors.New("media type unrecognized")
@@ -432,17 +440,17 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string
newMediaID := uuid.NewString()
URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
- originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension)
- smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension)
+ originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)
+ smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, mediaType, newMediaID, extension)
// we store the original...
- originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaOriginal, newMediaID, extension)
+ originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Original, newMediaID, extension)
if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil {
return nil, fmt.Errorf("storage error: %s", err)
}
// and a thumbnail...
- smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaSmall, newMediaID, extension)
+ smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Small, newMediaID, extension)
if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
return nil, fmt.Errorf("storage error: %s", err)
}
diff --git a/internal/media/media_test.go b/internal/media/media_test.go
@@ -29,7 +29,7 @@ import (
"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/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/storage"
)
@@ -78,7 +78,7 @@ func (suite *MediaTestSuite) SetupSuite() {
}
suite.config = c
// use an actual database for this, because it's just easier than mocking one out
- database, err := db.New(context.Background(), c, log)
+ database, err := db.NewPostgresService(context.Background(), c, log)
if err != nil {
suite.FailNow(err.Error())
}
diff --git a/internal/media/mock_MediaHandler.go b/internal/media/mock_MediaHandler.go
@@ -4,7 +4,7 @@ package media
import (
mock "github.com/stretchr/testify/mock"
- gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// MockMediaHandler is an autogenerated mock type for the MediaHandler type
diff --git a/internal/media/util.go b/internal/media/util.go
@@ -33,6 +33,26 @@ import (
"github.com/superseriousbusiness/exifremove/pkg/exifremove"
)
+const (
+ // MIMEImage is the mime type for image
+ MIMEImage = "image"
+ // MIMEJpeg is the jpeg image mime type
+ MIMEJpeg = "image/jpeg"
+ // MIMEGif is the gif image mime type
+ MIMEGif = "image/gif"
+ // MIMEPng is the png image mime type
+ MIMEPng = "image/png"
+
+ // MIMEVideo is the mime type for video
+ MIMEVideo = "video"
+ // MIMEMp4 is the mp4 video mime type
+ MIMEMp4 = "video/mp4"
+ // MIMEMpeg is the mpeg video mime type
+ MIMEMpeg = "video/mpeg"
+ // MIMEWebm is the webm video mime type
+ MIMEWebm = "video/webm"
+)
+
// parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg").
// Returns an error if the content type is not something we can process.
func parseContentType(content []byte) (string, error) {
@@ -54,13 +74,13 @@ func parseContentType(content []byte) (string, error) {
return kind.MIME.Value, nil
}
-// supportedImageType checks mime type of an image against a slice of accepted types,
+// SupportedImageType checks mime type of an image against a slice of accepted types,
// and returns True if the mime type is accepted.
-func supportedImageType(mimeType string) bool {
+func SupportedImageType(mimeType string) bool {
acceptedImageTypes := []string{
- "image/jpeg",
- "image/gif",
- "image/png",
+ MIMEJpeg,
+ MIMEGif,
+ MIMEPng,
}
for _, accepted := range acceptedImageTypes {
if mimeType == accepted {
@@ -70,13 +90,13 @@ func supportedImageType(mimeType string) bool {
return false
}
-// supportedVideoType checks mime type of a video against a slice of accepted types,
+// SupportedVideoType checks mime type of a video against a slice of accepted types,
// and returns True if the mime type is accepted.
-func supportedVideoType(mimeType string) bool {
+func SupportedVideoType(mimeType string) bool {
acceptedVideoTypes := []string{
- "video/mp4",
- "video/mpeg",
- "video/webm",
+ MIMEMp4,
+ MIMEMpeg,
+ MIMEWebm,
}
for _, accepted := range acceptedVideoTypes {
if mimeType == accepted {
@@ -89,8 +109,8 @@ func supportedVideoType(mimeType string) bool {
// supportedEmojiType checks that the content type is image/png -- the only type supported for emoji.
func supportedEmojiType(mimeType string) bool {
acceptedEmojiTypes := []string{
- "image/gif",
- "image/png",
+ MIMEGif,
+ MIMEPng,
}
for _, accepted := range acceptedEmojiTypes {
if mimeType == accepted {
@@ -121,7 +141,7 @@ func deriveGif(b []byte, extension string) (*imageAndMeta, error) {
var g *gif.GIF
var err error
switch extension {
- case "image/gif":
+ case MIMEGif:
g, err = gif.DecodeAll(bytes.NewReader(b))
if err != nil {
return nil, err
@@ -161,12 +181,12 @@ func deriveImage(b []byte, contentType string) (*imageAndMeta, error) {
var err error
switch contentType {
- case "image/jpeg":
+ case MIMEJpeg:
i, err = jpeg.Decode(bytes.NewReader(b))
if err != nil {
return nil, err
}
- case "image/png":
+ case MIMEPng:
i, err = png.Decode(bytes.NewReader(b))
if err != nil {
return nil, err
@@ -210,17 +230,17 @@ func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMet
var err error
switch contentType {
- case "image/jpeg":
+ case MIMEJpeg:
i, err = jpeg.Decode(bytes.NewReader(b))
if err != nil {
return nil, err
}
- case "image/png":
+ case MIMEPng:
i, err = png.Decode(bytes.NewReader(b))
if err != nil {
return nil, err
}
- case "image/gif":
+ case MIMEGif:
i, err = gif.Decode(bytes.NewReader(b))
if err != nil {
return nil, err
@@ -254,12 +274,12 @@ func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) {
var err error
switch contentType {
- case "image/png":
+ case MIMEPng:
i, err = png.Decode(bytes.NewReader(b))
if err != nil {
return nil, err
}
- case "image/gif":
+ case MIMEGif:
i, err = gif.Decode(bytes.NewReader(b))
if err != nil {
return nil, err
@@ -285,3 +305,31 @@ type imageAndMeta struct {
aspect float64
blurhash string
}
+
+// ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized
+func ParseMediaType(s string) (Type, error) {
+ switch Type(s) {
+ case Attachment:
+ return Attachment, nil
+ case Header:
+ return Header, nil
+ case Avatar:
+ return Avatar, nil
+ case Emoji:
+ return Emoji, nil
+ }
+ return "", fmt.Errorf("%s not a recognized MediaType", s)
+}
+
+// ParseMediaSize converts s to a recognized MediaSize, or returns an error if unrecognized
+func ParseMediaSize(s string) (Size, error) {
+ switch Size(s) {
+ case Small:
+ return Small, nil
+ case Original:
+ return Original, nil
+ case Static:
+ return Static, nil
+ }
+ return "", fmt.Errorf("%s not a recognized MediaSize", s)
+}
diff --git a/internal/media/util_test.go b/internal/media/util_test.go
@@ -135,10 +135,10 @@ func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() {
}
func (suite *MediaUtilTestSuite) TestSupportedImageTypes() {
- ok := supportedImageType("image/jpeg")
+ ok := SupportedImageType("image/jpeg")
assert.True(suite.T(), ok)
- ok = supportedImageType("image/bmp")
+ ok = SupportedImageType("image/bmp")
assert.False(suite.T(), ok)
}
diff --git a/internal/message/accountprocess.go b/internal/message/accountprocess.go
@@ -0,0 +1,168 @@
+package message
+
+import (
+ "errors"
+ "fmt"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// accountCreate does the dirty work of making an account and user in the database.
+// It then returns a token to the caller, for use with the new account, as per the
+// spec here: https://docs.joinmastodon.org/methods/accounts/
+func (p *processor) AccountCreate(authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) {
+ l := p.log.WithField("func", "accountCreate")
+
+ if err := p.db.IsEmailAvailable(form.Email); err != nil {
+ return nil, err
+ }
+
+ if err := p.db.IsUsernameAvailable(form.Username); err != nil {
+ return nil, err
+ }
+
+ // don't store a reason if we don't require one
+ reason := form.Reason
+ if !p.config.AccountsConfig.ReasonRequired {
+ reason = ""
+ }
+
+ l.Trace("creating new username and account")
+ user, err := p.db.NewSignup(form.Username, reason, p.config.AccountsConfig.RequireApproval, form.Email, form.Password, form.IP, form.Locale, authed.Application.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error creating new signup in the database: %s", err)
+ }
+
+ l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, authed.Application.ID)
+ accessToken, err := p.oauthServer.GenerateUserAccessToken(authed.Token, authed.Application.ClientSecret, user.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err)
+ }
+
+ return &apimodel.Token{
+ AccessToken: accessToken.GetAccess(),
+ TokenType: "Bearer",
+ Scope: accessToken.GetScope(),
+ CreatedAt: accessToken.GetAccessCreateAt().Unix(),
+ }, nil
+}
+
+func (p *processor) AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) {
+ targetAccount := >smodel.Account{}
+ if err := p.db.GetByID(targetAccountID, targetAccount); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ return nil, errors.New("account not found")
+ }
+ return nil, fmt.Errorf("db error: %s", err)
+ }
+
+ var mastoAccount *apimodel.Account
+ var err error
+ if authed.Account != nil && targetAccount.ID == authed.Account.ID {
+ mastoAccount, err = p.tc.AccountToMastoSensitive(targetAccount)
+ } else {
+ mastoAccount, err = p.tc.AccountToMastoPublic(targetAccount)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("error converting account: %s", err)
+ }
+ return mastoAccount, nil
+}
+
+func (p *processor) AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) {
+ l := p.log.WithField("func", "AccountUpdate")
+
+ if form.Discoverable != nil {
+ if err := p.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil {
+ return nil, fmt.Errorf("error updating discoverable: %s", err)
+ }
+ }
+
+ if form.Bot != nil {
+ if err := p.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil {
+ return nil, fmt.Errorf("error updating bot: %s", err)
+ }
+ }
+
+ if form.DisplayName != nil {
+ if err := util.ValidateDisplayName(*form.DisplayName); err != nil {
+ return nil, err
+ }
+ if err := p.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, >smodel.Account{}); err != nil {
+ return nil, err
+ }
+ }
+
+ if form.Note != nil {
+ if err := util.ValidateNote(*form.Note); err != nil {
+ return nil, err
+ }
+ if err := p.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, >smodel.Account{}); err != nil {
+ return nil, err
+ }
+ }
+
+ if form.Avatar != nil && form.Avatar.Size != 0 {
+ avatarInfo, err := p.updateAccountAvatar(form.Avatar, authed.Account.ID)
+ if err != nil {
+ return nil, err
+ }
+ l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo)
+ }
+
+ if form.Header != nil && form.Header.Size != 0 {
+ headerInfo, err := p.updateAccountHeader(form.Header, authed.Account.ID)
+ if err != nil {
+ return nil, err
+ }
+ l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo)
+ }
+
+ if form.Locked != nil {
+ if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
+ return nil, err
+ }
+ }
+
+ if form.Source != nil {
+ if form.Source.Language != nil {
+ if err := util.ValidateLanguage(*form.Source.Language); err != nil {
+ return nil, err
+ }
+ if err := p.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil {
+ return nil, err
+ }
+ }
+
+ if form.Source.Sensitive != nil {
+ if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil {
+ return nil, err
+ }
+ }
+
+ if form.Source.Privacy != nil {
+ if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil {
+ return nil, err
+ }
+ if err := p.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ // fetch the account with all updated values set
+ updatedAccount := >smodel.Account{}
+ if err := p.db.GetByID(authed.Account.ID, updatedAccount); err != nil {
+ return nil, fmt.Errorf("could not fetch updated account %s: %s", authed.Account.ID, err)
+ }
+
+ acctSensitive, err := p.tc.AccountToMastoSensitive(updatedAccount)
+ if err != nil {
+ return nil, fmt.Errorf("could not convert account into mastosensitive account: %s", err)
+ }
+ return acctSensitive, nil
+}
diff --git a/internal/message/adminprocess.go b/internal/message/adminprocess.go
@@ -0,0 +1,48 @@
+package message
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (p *processor) AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) {
+ if !authed.User.Admin {
+ return nil, fmt.Errorf("user %s not an admin", authed.User.ID)
+ }
+
+ // open the emoji and extract the bytes from it
+ f, err := form.Image.Open()
+ if err != nil {
+ return nil, fmt.Errorf("error opening emoji: %s", err)
+ }
+ buf := new(bytes.Buffer)
+ size, err := io.Copy(buf, f)
+ if err != nil {
+ return nil, fmt.Errorf("error reading emoji: %s", err)
+ }
+ if size == 0 {
+ return nil, errors.New("could not read provided emoji: size 0 bytes")
+ }
+
+ // allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using
+ emoji, err := p.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode)
+ if err != nil {
+ return nil, fmt.Errorf("error reading emoji: %s", err)
+ }
+
+ mastoEmoji, err := p.tc.EmojiToMasto(emoji)
+ if err != nil {
+ return nil, fmt.Errorf("error converting emoji to mastotype: %s", err)
+ }
+
+ if err := p.db.Put(emoji); err != nil {
+ return nil, fmt.Errorf("database error while processing emoji: %s", err)
+ }
+
+ return &mastoEmoji, nil
+}
diff --git a/internal/message/appprocess.go b/internal/message/appprocess.go
@@ -0,0 +1,59 @@
+package message
+
+import (
+ "github.com/google/uuid"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (p *processor) AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) {
+ // set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/
+ var scopes string
+ if form.Scopes == "" {
+ scopes = "read"
+ } else {
+ scopes = form.Scopes
+ }
+
+ // generate new IDs for this application and its associated client
+ clientID := uuid.NewString()
+ clientSecret := uuid.NewString()
+ vapidKey := uuid.NewString()
+
+ // generate the application to put in the database
+ app := >smodel.Application{
+ Name: form.ClientName,
+ Website: form.Website,
+ RedirectURI: form.RedirectURIs,
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ Scopes: scopes,
+ VapidKey: vapidKey,
+ }
+
+ // chuck it in the db
+ if err := p.db.Put(app); err != nil {
+ return nil, err
+ }
+
+ // now we need to model an oauth client from the application that the oauth library can use
+ oc := &oauth.Client{
+ ID: clientID,
+ Secret: clientSecret,
+ Domain: form.RedirectURIs,
+ UserID: "", // This client isn't yet associated with a specific user, it's just an app client right now
+ }
+
+ // chuck it in the db
+ if err := p.db.Put(oc); err != nil {
+ return nil, err
+ }
+
+ mastoApp, err := p.tc.AppToMastoSensitive(app)
+ if err != nil {
+ return nil, err
+ }
+
+ return mastoApp, nil
+}
diff --git a/internal/message/error.go b/internal/message/error.go
@@ -0,0 +1,106 @@
+package message
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+)
+
+// ErrorWithCode wraps an internal error with an http code, and a 'safe' version of
+// the error that can be served to clients without revealing internal business logic.
+//
+// A typical use of this error would be to first log the Original error, then return
+// the Safe error and the StatusCode to an API caller.
+type ErrorWithCode interface {
+ // Error returns the original internal error for debugging within the GoToSocial logs.
+ // This should *NEVER* be returned to a client as it may contain sensitive information.
+ Error() string
+ // Safe returns the API-safe version of the error for serialization towards a client.
+ // There's not much point logging this internally because it won't contain much helpful information.
+ Safe() string
+ // Code returns the status code for serving to a client.
+ Code() int
+}
+
+type errorWithCode struct {
+ original error
+ safe error
+ code int
+}
+
+func (e errorWithCode) Error() string {
+ return e.original.Error()
+}
+
+func (e errorWithCode) Safe() string {
+ return e.safe.Error()
+}
+
+func (e errorWithCode) Code() int {
+ return e.code
+}
+
+// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text.
+func NewErrorBadRequest(original error, helpText ...string) ErrorWithCode {
+ safe := "bad request"
+ if helpText != nil {
+ safe = safe + ": " + strings.Join(helpText, ": ")
+ }
+ return errorWithCode{
+ original: original,
+ safe: errors.New(safe),
+ code: http.StatusBadRequest,
+ }
+}
+
+// NewErrorNotAuthorized returns an ErrorWithCode 401 with the given original error and optional help text.
+func NewErrorNotAuthorized(original error, helpText ...string) ErrorWithCode {
+ safe := "not authorized"
+ if helpText != nil {
+ safe = safe + ": " + strings.Join(helpText, ": ")
+ }
+ return errorWithCode{
+ original: original,
+ safe: errors.New(safe),
+ code: http.StatusUnauthorized,
+ }
+}
+
+// NewErrorForbidden returns an ErrorWithCode 403 with the given original error and optional help text.
+func NewErrorForbidden(original error, helpText ...string) ErrorWithCode {
+ safe := "forbidden"
+ if helpText != nil {
+ safe = safe + ": " + strings.Join(helpText, ": ")
+ }
+ return errorWithCode{
+ original: original,
+ safe: errors.New(safe),
+ code: http.StatusForbidden,
+ }
+}
+
+// NewErrorNotFound returns an ErrorWithCode 404 with the given original error and optional help text.
+func NewErrorNotFound(original error, helpText ...string) ErrorWithCode {
+ safe := "404 not found"
+ if helpText != nil {
+ safe = safe + ": " + strings.Join(helpText, ": ")
+ }
+ return errorWithCode{
+ original: original,
+ safe: errors.New(safe),
+ code: http.StatusNotFound,
+ }
+}
+
+// NewErrorInternalError returns an ErrorWithCode 500 with the given original error and optional help text.
+func NewErrorInternalError(original error, helpText ...string) ErrorWithCode {
+ safe := "internal server error"
+ if helpText != nil {
+ safe = safe + ": " + strings.Join(helpText, ": ")
+ }
+ return errorWithCode{
+ original: original,
+ safe: errors.New(safe),
+ code: http.StatusInternalServerError,
+ }
+}
diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go
@@ -0,0 +1,102 @@
+package message
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/go-fed/activity/streams"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// authenticateAndDereferenceFediRequest authenticates the HTTP signature of an incoming federation request, using the given
+// username to perform the validation. It will *also* dereference the originator of the request and return it as a gtsmodel account
+// for further processing. NOTE that this function will have the side effect of putting the dereferenced account into the database,
+// and passing it into the processor through a channel for further asynchronous processing.
+func (p *processor) authenticateAndDereferenceFediRequest(username string, r *http.Request) (*gtsmodel.Account, error) {
+
+ // first authenticate
+ requestingAccountURI, err := p.federator.AuthenticateFederatedRequest(username, r)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't authenticate request for username %s: %s", username, err)
+ }
+
+ // OK now we can do the dereferencing part
+ // we might already have an entry for this account so check that first
+ requestingAccount := >smodel.Account{}
+
+ err = p.db.GetWhere("uri", requestingAccountURI.String(), requestingAccount)
+ if err == nil {
+ // we do have it yay, return it
+ return requestingAccount, nil
+ }
+
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ // something has actually gone wrong so bail
+ return nil, fmt.Errorf("database error getting account with uri %s: %s", requestingAccountURI.String(), err)
+ }
+
+ // we just don't have an entry for this account yet
+ // what we do now should depend on our chosen federation method
+ // for now though, we'll just dereference it
+ // TODO: slow-fed
+ requestingPerson, err := p.federator.DereferenceRemoteAccount(username, requestingAccountURI)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't dereference %s: %s", requestingAccountURI.String(), err)
+ }
+
+ // convert it to our internal account representation
+ requestingAccount, err = p.tc.ASRepresentationToAccount(requestingPerson)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't convert dereferenced uri %s to gtsmodel account: %s", requestingAccountURI.String(), err)
+ }
+
+ // shove it in the database for later
+ if err := p.db.Put(requestingAccount); err != nil {
+ return nil, fmt.Errorf("database error inserting account with uri %s: %s", requestingAccountURI.String(), err)
+ }
+
+ // put it in our channel to queue it for async processing
+ p.FromFederator() <- FromFederator{
+ APObjectType: gtsmodel.ActivityStreamsProfile,
+ APActivityType: gtsmodel.ActivityStreamsCreate,
+ Activity: requestingAccount,
+ }
+
+ return requestingAccount, nil
+}
+
+func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) {
+ // get the account the request is referring to
+ requestedAccount := >smodel.Account{}
+ if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil {
+ return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
+ }
+
+ // authenticate the request
+ requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request)
+ if err != nil {
+ return nil, NewErrorNotAuthorized(err)
+ }
+
+ blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID)
+ if err != nil {
+ return nil, NewErrorInternalError(err)
+ }
+
+ if blocked {
+ return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
+ }
+
+ requestedPerson, err := p.tc.AccountToAS(requestedAccount)
+ if err != nil {
+ return nil, NewErrorInternalError(err)
+ }
+
+ data, err := streams.Serialize(requestedPerson)
+ if err != nil {
+ return nil, NewErrorInternalError(err)
+ }
+
+ return data, nil
+}
diff --git a/internal/message/mediaprocess.go b/internal/message/mediaprocess.go
@@ -0,0 +1,188 @@
+package message
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+func (p *processor) MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) {
+ // First check this user/account is permitted to create media
+ // There's no point continuing otherwise.
+ if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
+ return nil, errors.New("not authorized to post new media")
+ }
+
+ // open the attachment and extract the bytes from it
+ f, err := form.File.Open()
+ if err != nil {
+ return nil, fmt.Errorf("error opening attachment: %s", err)
+ }
+ buf := new(bytes.Buffer)
+ size, err := io.Copy(buf, f)
+ if err != nil {
+ return nil, fmt.Errorf("error reading attachment: %s", err)
+
+ }
+ if size == 0 {
+ return nil, errors.New("could not read provided attachment: size 0 bytes")
+ }
+
+ // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using
+ attachment, err := p.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error reading attachment: %s", err)
+ }
+
+ // now we need to add extra fields that the attachment processor doesn't know (from the form)
+ // TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it)
+
+ // first description
+ attachment.Description = form.Description
+
+ // now parse the focus parameter
+ // TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated
+ var focusx, focusy float32
+ if form.Focus != "" {
+ spl := strings.Split(form.Focus, ",")
+ if len(spl) != 2 {
+ return nil, fmt.Errorf("improperly formatted focus %s", form.Focus)
+ }
+ xStr := spl[0]
+ yStr := spl[1]
+ if xStr == "" || yStr == "" {
+ return nil, fmt.Errorf("improperly formatted focus %s", form.Focus)
+ }
+ fx, err := strconv.ParseFloat(xStr, 32)
+ if err != nil {
+ return nil, fmt.Errorf("improperly formatted focus %s: %s", form.Focus, err)
+ }
+ if fx > 1 || fx < -1 {
+ return nil, fmt.Errorf("improperly formatted focus %s", form.Focus)
+ }
+ focusx = float32(fx)
+ fy, err := strconv.ParseFloat(yStr, 32)
+ if err != nil {
+ return nil, fmt.Errorf("improperly formatted focus %s: %s", form.Focus, err)
+ }
+ if fy > 1 || fy < -1 {
+ return nil, fmt.Errorf("improperly formatted focus %s", form.Focus)
+ }
+ focusy = float32(fy)
+ }
+ attachment.FileMeta.Focus.X = focusx
+ attachment.FileMeta.Focus.Y = focusy
+
+ // prepare the frontend representation now -- if there are any errors here at least we can bail without
+ // having already put something in the database and then having to clean it up again (eugh)
+ mastoAttachment, err := p.tc.AttachmentToMasto(attachment)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing media attachment to frontend type: %s", err)
+ }
+
+ // now we can confidently put the attachment in the database
+ if err := p.db.Put(attachment); err != nil {
+ return nil, fmt.Errorf("error storing media attachment in db: %s", err)
+ }
+
+ return &mastoAttachment, nil
+}
+
+func (p *processor) MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) {
+ // parse the form fields
+ mediaSize, err := media.ParseMediaSize(form.MediaSize)
+ if err != nil {
+ return nil, NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize))
+ }
+
+ mediaType, err := media.ParseMediaType(form.MediaType)
+ if err != nil {
+ return nil, NewErrorNotFound(fmt.Errorf("media type %s not valid", form.MediaType))
+ }
+
+ spl := strings.Split(form.FileName, ".")
+ if len(spl) != 2 || spl[0] == "" || spl[1] == "" {
+ return nil, NewErrorNotFound(fmt.Errorf("file name %s not parseable", form.FileName))
+ }
+ wantedMediaID := spl[0]
+
+ // get the account that owns the media and make sure it's not suspended
+ acct := >smodel.Account{}
+ if err := p.db.GetByID(form.AccountID, acct); err != nil {
+ return nil, NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", form.AccountID, err))
+ }
+ if !acct.SuspendedAt.IsZero() {
+ return nil, NewErrorNotFound(fmt.Errorf("account with id %s is suspended", form.AccountID))
+ }
+
+ // make sure the requesting account and the media account don't block each other
+ if authed.Account != nil {
+ blocked, err := p.db.Blocked(authed.Account.ID, form.AccountID)
+ if err != nil {
+ return nil, NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", form.AccountID, authed.Account.ID, err))
+ }
+ if blocked {
+ return nil, NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", form.AccountID, authed.Account.ID))
+ }
+ }
+
+ // the way we store emojis is a little different from the way we store other attachments,
+ // so we need to take different steps depending on the media type being requested
+ content := &apimodel.Content{}
+ var storagePath string
+ switch mediaType {
+ case media.Emoji:
+ e := >smodel.Emoji{}
+ if err := p.db.GetByID(wantedMediaID, e); err != nil {
+ return nil, NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", wantedMediaID, err))
+ }
+ if e.Disabled {
+ return nil, NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", wantedMediaID))
+ }
+ switch mediaSize {
+ case media.Original:
+ content.ContentType = e.ImageContentType
+ storagePath = e.ImagePath
+ case media.Static:
+ content.ContentType = e.ImageStaticContentType
+ storagePath = e.ImageStaticPath
+ default:
+ return nil, NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", mediaSize))
+ }
+ case media.Attachment, media.Header, media.Avatar:
+ a := >smodel.MediaAttachment{}
+ if err := p.db.GetByID(wantedMediaID, a); err != nil {
+ return nil, NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err))
+ }
+ if a.AccountID != form.AccountID {
+ return nil, NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, form.AccountID))
+ }
+ switch mediaSize {
+ case media.Original:
+ content.ContentType = a.File.ContentType
+ storagePath = a.File.Path
+ case media.Small:
+ content.ContentType = a.Thumbnail.ContentType
+ storagePath = a.Thumbnail.Path
+ default:
+ return nil, NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize))
+ }
+ }
+
+ bytes, err := p.storage.RetrieveFileFrom(storagePath)
+ if err != nil {
+ return nil, NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err))
+ }
+
+ content.ContentLength = int64(len(bytes))
+ content.Content = bytes
+ return content, nil
+}
diff --git a/internal/message/processor.go b/internal/message/processor.go
@@ -0,0 +1,215 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package message
+
+import (
+ "net/http"
+
+ "github.com/sirupsen/logrus"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// Processor should be passed to api modules (see internal/apimodule/...). It is used for
+// passing messages back and forth from the client API and the federating interface, via channels.
+// It also contains logic for filtering which messages should end up where.
+// It is designed to be used asynchronously: the client API and the federating API should just be able to
+// fire messages into the processor and not wait for a reply before proceeding with other work. This allows
+// for clean distribution of messages without slowing down the client API and harming the user experience.
+type Processor interface {
+ // ToClientAPI returns a channel for putting in messages that need to go to the gts client API.
+ ToClientAPI() chan ToClientAPI
+ // FromClientAPI returns a channel for putting messages in that come from the client api going to the processor
+ FromClientAPI() chan FromClientAPI
+ // ToFederator returns a channel for putting in messages that need to go to the federator (activitypub).
+ ToFederator() chan ToFederator
+ // FromFederator returns a channel for putting messages in that come from the federator (activitypub) going into the processor
+ FromFederator() chan FromFederator
+ // Start starts the Processor, reading from its channels and passing messages back and forth.
+ Start() error
+ // Stop stops the processor cleanly, finishing handling any remaining messages before closing down.
+ Stop() error
+
+ /*
+ CLIENT API-FACING PROCESSING FUNCTIONS
+ These functions are intended to be called when the API client needs an immediate (ie., synchronous) reply
+ to an HTTP request. As such, they will only do the bare-minimum of work necessary to give a properly
+ formed reply. For more intensive (and time-consuming) calls, where you don't require an immediate
+ response, pass work to the processor using a channel instead.
+ */
+
+ // AccountCreate processes the given form for creating a new account, returning an oauth token for that account if successful.
+ AccountCreate(authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error)
+ // AccountGet processes the given request for account information.
+ AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error)
+ // AccountUpdate processes the update of an account with the given form
+ AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
+
+ // AppCreate processes the creation of a new API application
+ AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error)
+
+ // StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK.
+ StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error)
+ // StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through.
+ StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
+ // StatusFave processes the faving of a given status, returning the updated status if the fave goes through.
+ StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
+ // StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings.
+ StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error)
+ // StatusGet gets the given status, taking account of privacy settings and blocks etc.
+ StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
+ // StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through.
+ StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
+
+ // MediaCreate handles the creation of a media attachment, using the given form.
+ MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error)
+ // MediaGet handles the fetching of a media attachment, using the given request form.
+ MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error)
+ // AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form.
+ AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error)
+
+ /*
+ FEDERATION API-FACING PROCESSING FUNCTIONS
+ These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply
+ to an HTTP request. As such, they will only do the bare-minimum of work necessary to give a properly
+ formed reply. For more intensive (and time-consuming) calls, where you don't require an immediate
+ response, pass work to the processor using a channel instead.
+ */
+
+ // GetFediUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication
+ // before returning a JSON serializable interface to the caller.
+ GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode)
+}
+
+// processor just implements the Processor interface
+type processor struct {
+ // federator pub.FederatingActor
+ toClientAPI chan ToClientAPI
+ fromClientAPI chan FromClientAPI
+ toFederator chan ToFederator
+ fromFederator chan FromFederator
+ federator federation.Federator
+ stop chan interface{}
+ log *logrus.Logger
+ config *config.Config
+ tc typeutils.TypeConverter
+ oauthServer oauth.Server
+ mediaHandler media.Handler
+ storage storage.Storage
+ db db.DB
+}
+
+// NewProcessor returns a new Processor that uses the given federator and logger
+func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator federation.Federator, oauthServer oauth.Server, mediaHandler media.Handler, storage storage.Storage, db db.DB, log *logrus.Logger) Processor {
+ return &processor{
+ toClientAPI: make(chan ToClientAPI, 100),
+ fromClientAPI: make(chan FromClientAPI, 100),
+ toFederator: make(chan ToFederator, 100),
+ fromFederator: make(chan FromFederator, 100),
+ federator: federator,
+ stop: make(chan interface{}),
+ log: log,
+ config: config,
+ tc: tc,
+ oauthServer: oauthServer,
+ mediaHandler: mediaHandler,
+ storage: storage,
+ db: db,
+ }
+}
+
+func (p *processor) ToClientAPI() chan ToClientAPI {
+ return p.toClientAPI
+}
+
+func (p *processor) FromClientAPI() chan FromClientAPI {
+ return p.fromClientAPI
+}
+
+func (p *processor) ToFederator() chan ToFederator {
+ return p.toFederator
+}
+
+func (p *processor) FromFederator() chan FromFederator {
+ return p.fromFederator
+}
+
+// Start starts the Processor, reading from its channels and passing messages back and forth.
+func (p *processor) Start() error {
+ go func() {
+ DistLoop:
+ for {
+ select {
+ case clientMsg := <-p.toClientAPI:
+ p.log.Infof("received message TO client API: %+v", clientMsg)
+ case clientMsg := <-p.fromClientAPI:
+ p.log.Infof("received message FROM client API: %+v", clientMsg)
+ case federatorMsg := <-p.toFederator:
+ p.log.Infof("received message TO federator: %+v", federatorMsg)
+ case federatorMsg := <-p.fromFederator:
+ p.log.Infof("received message FROM federator: %+v", federatorMsg)
+ case <-p.stop:
+ break DistLoop
+ }
+ }
+ }()
+ return nil
+}
+
+// Stop stops the processor cleanly, finishing handling any remaining messages before closing down.
+// TODO: empty message buffer properly before stopping otherwise we'll lose federating messages.
+func (p *processor) Stop() error {
+ close(p.stop)
+ return nil
+}
+
+// ToClientAPI wraps a message that travels from the processor into the client API
+type ToClientAPI struct {
+ APObjectType gtsmodel.ActivityStreamsObject
+ APActivityType gtsmodel.ActivityStreamsActivity
+ Activity interface{}
+}
+
+// FromClientAPI wraps a message that travels from client API into the processor
+type FromClientAPI struct {
+ APObjectType gtsmodel.ActivityStreamsObject
+ APActivityType gtsmodel.ActivityStreamsActivity
+ Activity interface{}
+}
+
+// ToFederator wraps a message that travels from the processor into the federator
+type ToFederator struct {
+ APObjectType gtsmodel.ActivityStreamsObject
+ APActivityType gtsmodel.ActivityStreamsActivity
+ Activity interface{}
+}
+
+// FromFederator wraps a message that travels from the federator into the processor
+type FromFederator struct {
+ APObjectType gtsmodel.ActivityStreamsObject
+ APActivityType gtsmodel.ActivityStreamsActivity
+ Activity interface{}
+}
diff --git a/internal/message/processorutil.go b/internal/message/processorutil.go
@@ -0,0 +1,304 @@
+package message
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (p *processor) processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error {
+ // by default all flags are set to true
+ gtsAdvancedVis := >smodel.VisibilityAdvanced{
+ Federated: true,
+ Boostable: true,
+ Replyable: true,
+ Likeable: true,
+ }
+
+ var gtsBasicVis gtsmodel.Visibility
+ // Advanced takes priority if it's set.
+ // If it's not set, take whatever masto visibility is set.
+ // If *that's* not set either, then just take the account default.
+ // If that's also not set, take the default for the whole instance.
+ if form.VisibilityAdvanced != nil {
+ gtsBasicVis = gtsmodel.Visibility(*form.VisibilityAdvanced)
+ } else if form.Visibility != "" {
+ gtsBasicVis = p.tc.MastoVisToVis(form.Visibility)
+ } else if accountDefaultVis != "" {
+ gtsBasicVis = accountDefaultVis
+ } else {
+ gtsBasicVis = gtsmodel.VisibilityDefault
+ }
+
+ switch gtsBasicVis {
+ case gtsmodel.VisibilityPublic:
+ // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
+ break
+ case gtsmodel.VisibilityUnlocked:
+ // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
+ if form.Federated != nil {
+ gtsAdvancedVis.Federated = *form.Federated
+ }
+
+ if form.Boostable != nil {
+ gtsAdvancedVis.Boostable = *form.Boostable
+ }
+
+ if form.Replyable != nil {
+ gtsAdvancedVis.Replyable = *form.Replyable
+ }
+
+ if form.Likeable != nil {
+ gtsAdvancedVis.Likeable = *form.Likeable
+ }
+
+ case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
+ // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
+ gtsAdvancedVis.Boostable = false
+
+ if form.Federated != nil {
+ gtsAdvancedVis.Federated = *form.Federated
+ }
+
+ if form.Replyable != nil {
+ gtsAdvancedVis.Replyable = *form.Replyable
+ }
+
+ if form.Likeable != nil {
+ gtsAdvancedVis.Likeable = *form.Likeable
+ }
+
+ case gtsmodel.VisibilityDirect:
+ // direct is pretty easy: there's only one possible setting so return it
+ gtsAdvancedVis.Federated = true
+ gtsAdvancedVis.Boostable = false
+ gtsAdvancedVis.Federated = true
+ gtsAdvancedVis.Likeable = true
+ }
+
+ status.Visibility = gtsBasicVis
+ status.VisibilityAdvanced = gtsAdvancedVis
+ return nil
+}
+
+func (p *processor) processReplyToID(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
+ if form.InReplyToID == "" {
+ return nil
+ }
+
+ // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
+ //
+ // 1. Does the replied status exist in the database?
+ // 2. Is the replied status marked as replyable?
+ // 3. Does a block exist between either the current account or the account that posted the status it's replying to?
+ //
+ // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
+ repliedStatus := >smodel.Status{}
+ repliedAccount := >smodel.Account{}
+ // check replied status exists + is replyable
+ if err := p.db.GetByID(form.InReplyToID, repliedStatus); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID)
+ }
+ return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+ }
+
+ if !repliedStatus.VisibilityAdvanced.Replyable {
+ return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID)
+ }
+
+ // check replied account is known to us
+ if err := p.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil {
+ if _, ok := err.(db.ErrNoEntries); ok {
+ return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID)
+ }
+ return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+ }
+ // check if a block exists
+ if blocked, err := p.db.Blocked(thisAccountID, repliedAccount.ID); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err)
+ }
+ } else if blocked {
+ return fmt.Errorf("status with id %s not replyable", form.InReplyToID)
+ }
+ status.InReplyToID = repliedStatus.ID
+ status.InReplyToAccountID = repliedAccount.ID
+
+ return nil
+}
+
+func (p *processor) processMediaIDs(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error {
+ if form.MediaIDs == nil {
+ return nil
+ }
+
+ gtsMediaAttachments := []*gtsmodel.MediaAttachment{}
+ attachments := []string{}
+ for _, mediaID := range form.MediaIDs {
+ // check these attachments exist
+ a := >smodel.MediaAttachment{}
+ if err := p.db.GetByID(mediaID, a); err != nil {
+ return fmt.Errorf("invalid media type or media not found for media id %s", mediaID)
+ }
+ // check they belong to the requesting account id
+ if a.AccountID != thisAccountID {
+ return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID)
+ }
+ // check they're not already used in a status
+ if a.StatusID != "" || a.ScheduledStatusID != "" {
+ return fmt.Errorf("media with id %s is already attached to a status", mediaID)
+ }
+ gtsMediaAttachments = append(gtsMediaAttachments, a)
+ attachments = append(attachments, a.ID)
+ }
+ status.GTSMediaAttachments = gtsMediaAttachments
+ status.Attachments = attachments
+ return nil
+}
+
+func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error {
+ if form.Language != "" {
+ status.Language = form.Language
+ } else {
+ status.Language = accountDefaultLanguage
+ }
+ if status.Language == "" {
+ return errors.New("no language given either in status create form or account default")
+ }
+ return nil
+}
+
+func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+ menchies := []string{}
+ gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID)
+ if err != nil {
+ return fmt.Errorf("error generating mentions from status: %s", err)
+ }
+ for _, menchie := range gtsMenchies {
+ if err := p.db.Put(menchie); err != nil {
+ return fmt.Errorf("error putting mentions in db: %s", err)
+ }
+ menchies = append(menchies, menchie.TargetAccountID)
+ }
+ // add full populated gts menchies to the status for passing them around conveniently
+ status.GTSMentions = gtsMenchies
+ // add just the ids of the mentioned accounts to the status for putting in the db
+ status.Mentions = menchies
+ return nil
+}
+
+func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+ tags := []string{}
+ gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID)
+ if err != nil {
+ return fmt.Errorf("error generating hashtags from status: %s", err)
+ }
+ for _, tag := range gtsTags {
+ if err := p.db.Upsert(tag, "name"); err != nil {
+ return fmt.Errorf("error putting tags in db: %s", err)
+ }
+ tags = append(tags, tag.ID)
+ }
+ // add full populated gts tags to the status for passing them around conveniently
+ status.GTSTags = gtsTags
+ // add just the ids of the used tags to the status for putting in the db
+ status.Tags = tags
+ return nil
+}
+
+func (p *processor) processEmojis(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error {
+ emojis := []string{}
+ gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID)
+ if err != nil {
+ return fmt.Errorf("error generating emojis from status: %s", err)
+ }
+ for _, e := range gtsEmojis {
+ emojis = append(emojis, e.ID)
+ }
+ // add full populated gts emojis to the status for passing them around conveniently
+ status.GTSEmojis = gtsEmojis
+ // add just the ids of the used emojis to the status for putting in the db
+ status.Emojis = emojis
+ return nil
+}
+
+/*
+ HELPER FUNCTIONS
+*/
+
+// TODO: try to combine the below two functions because this is a lot of code repetition.
+
+// updateAccountAvatar does the dirty work of checking the avatar part of an account update form,
+// parsing and checking the image, and doing the necessary updates in the database for this to become
+// the account's new avatar image.
+func (p *processor) updateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
+ var err error
+ if int(avatar.Size) > p.config.MediaConfig.MaxImageSize {
+ err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, p.config.MediaConfig.MaxImageSize)
+ return nil, err
+ }
+ f, err := avatar.Open()
+ if err != nil {
+ return nil, fmt.Errorf("could not read provided avatar: %s", err)
+ }
+
+ // extract the bytes
+ buf := new(bytes.Buffer)
+ size, err := io.Copy(buf, f)
+ if err != nil {
+ return nil, fmt.Errorf("could not read provided avatar: %s", err)
+ }
+ if size == 0 {
+ return nil, errors.New("could not read provided avatar: size 0 bytes")
+ }
+
+ // do the setting
+ avatarInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Avatar)
+ if err != nil {
+ return nil, fmt.Errorf("error processing avatar: %s", err)
+ }
+
+ return avatarInfo, f.Close()
+}
+
+// updateAccountHeader does the dirty work of checking the header part of an account update form,
+// parsing and checking the image, and doing the necessary updates in the database for this to become
+// the account's new header image.
+func (p *processor) updateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) {
+ var err error
+ if int(header.Size) > p.config.MediaConfig.MaxImageSize {
+ err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, p.config.MediaConfig.MaxImageSize)
+ return nil, err
+ }
+ f, err := header.Open()
+ if err != nil {
+ return nil, fmt.Errorf("could not read provided header: %s", err)
+ }
+
+ // extract the bytes
+ buf := new(bytes.Buffer)
+ size, err := io.Copy(buf, f)
+ if err != nil {
+ return nil, fmt.Errorf("could not read provided header: %s", err)
+ }
+ if size == 0 {
+ return nil, errors.New("could not read provided header: size 0 bytes")
+ }
+
+ // do the setting
+ headerInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Header)
+ if err != nil {
+ return nil, fmt.Errorf("error processing header: %s", err)
+ }
+
+ return headerInfo, f.Close()
+}
diff --git a/internal/message/statusprocess.go b/internal/message/statusprocess.go
@@ -0,0 +1,350 @@
+package message
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (p *processor) StatusCreate(auth *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) {
+ uris := util.GenerateURIsForAccount(auth.Account.Username, p.config.Protocol, p.config.Host)
+ thisStatusID := uuid.NewString()
+ thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID)
+ thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID)
+ newStatus := >smodel.Status{
+ ID: thisStatusID,
+ URI: thisStatusURI,
+ URL: thisStatusURL,
+ Content: util.HTMLFormat(form.Status),
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ Local: true,
+ AccountID: auth.Account.ID,
+ ContentWarning: form.SpoilerText,
+ ActivityStreamsType: gtsmodel.ActivityStreamsNote,
+ Sensitive: form.Sensitive,
+ Language: form.Language,
+ CreatedWithApplicationID: auth.Application.ID,
+ Text: form.Status,
+ }
+
+ // check if replyToID is ok
+ if err := p.processReplyToID(form, auth.Account.ID, newStatus); err != nil {
+ return nil, err
+ }
+
+ // check if mediaIDs are ok
+ if err := p.processMediaIDs(form, auth.Account.ID, newStatus); err != nil {
+ return nil, err
+ }
+
+ // check if visibility settings are ok
+ if err := p.processVisibility(form, auth.Account.Privacy, newStatus); err != nil {
+ return nil, err
+ }
+
+ // handle language settings
+ if err := p.processLanguage(form, auth.Account.Language, newStatus); err != nil {
+ return nil, err
+ }
+
+ // handle mentions
+ if err := p.processMentions(form, auth.Account.ID, newStatus); err != nil {
+ return nil, err
+ }
+
+ if err := p.processTags(form, auth.Account.ID, newStatus); err != nil {
+ return nil, err
+ }
+
+ if err := p.processEmojis(form, auth.Account.ID, newStatus); err != nil {
+ return nil, err
+ }
+
+ // put the new status in the database, generating an ID for it in the process
+ if err := p.db.Put(newStatus); err != nil {
+ return nil, err
+ }
+
+ // change the status ID of the media attachments to the new status
+ for _, a := range newStatus.GTSMediaAttachments {
+ a.StatusID = newStatus.ID
+ a.UpdatedAt = time.Now()
+ if err := p.db.UpdateByID(a.ID, a); err != nil {
+ return nil, err
+ }
+ }
+
+ // return the frontend representation of the new status to the submitter
+ return p.tc.StatusToMasto(newStatus, auth.Account, auth.Account, nil, newStatus.GTSReplyToAccount, nil)
+}
+
+func (p *processor) StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) {
+ l := p.log.WithField("func", "StatusDelete")
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := >smodel.Status{}
+ if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+ return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+ }
+
+ if targetStatus.AccountID != authed.Account.ID {
+ return nil, errors.New("status doesn't belong to requesting account")
+ }
+
+ l.Trace("going to get relevant accounts")
+ relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+ }
+
+ var boostOfStatus *gtsmodel.Status
+ if targetStatus.BoostOfID != "" {
+ boostOfStatus = >smodel.Status{}
+ if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+ return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+ }
+ }
+
+ mastoStatus, err := p.tc.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+ }
+
+ if err := p.db.DeleteByID(targetStatus.ID, targetStatus); err != nil {
+ return nil, fmt.Errorf("error deleting status from the database: %s", err)
+ }
+
+ return mastoStatus, nil
+}
+
+func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) {
+ l := p.log.WithField("func", "StatusFave")
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := >smodel.Status{}
+ if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+ return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+ }
+
+ l.Tracef("going to search for target account %s", targetStatus.AccountID)
+ targetAccount := >smodel.Account{}
+ if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+ return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+ }
+
+ l.Trace("going to get relevant accounts")
+ relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+ }
+
+ l.Trace("going to see if status is visible")
+ visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+ if err != nil {
+ return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+ }
+
+ if !visible {
+ return nil, errors.New("status is not visible")
+ }
+
+ // is the status faveable?
+ if !targetStatus.VisibilityAdvanced.Likeable {
+ return nil, errors.New("status is not faveable")
+ }
+
+ // it's visible! it's faveable! so let's fave the FUCK out of it
+ _, err = p.db.FaveStatus(targetStatus, authed.Account.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error faveing status: %s", err)
+ }
+
+ var boostOfStatus *gtsmodel.Status
+ if targetStatus.BoostOfID != "" {
+ boostOfStatus = >smodel.Status{}
+ if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+ return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+ }
+ }
+
+ mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+ }
+
+ return mastoStatus, nil
+}
+
+func (p *processor) StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) {
+ l := p.log.WithField("func", "StatusFavedBy")
+
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := >smodel.Status{}
+ if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+ return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+ }
+
+ l.Tracef("going to search for target account %s", targetStatus.AccountID)
+ targetAccount := >smodel.Account{}
+ if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+ return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+ }
+
+ l.Trace("going to get relevant accounts")
+ relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+ }
+
+ l.Trace("going to see if status is visible")
+ visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+ if err != nil {
+ return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+ }
+
+ if !visible {
+ return nil, errors.New("status is not visible")
+ }
+
+ // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
+ favingAccounts, err := p.db.WhoFavedStatus(targetStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error seeing who faved status: %s", err)
+ }
+
+ // filter the list so the user doesn't see accounts they blocked or which blocked them
+ filteredAccounts := []*gtsmodel.Account{}
+ for _, acc := range favingAccounts {
+ blocked, err := p.db.Blocked(authed.Account.ID, acc.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error checking blocks: %s", err)
+ }
+ if !blocked {
+ filteredAccounts = append(filteredAccounts, acc)
+ }
+ }
+
+ // TODO: filter other things here? suspended? muted? silenced?
+
+ // now we can return the masto representation of those accounts
+ mastoAccounts := []*apimodel.Account{}
+ for _, acc := range filteredAccounts {
+ mastoAccount, err := p.tc.AccountToMastoPublic(acc)
+ if err != nil {
+ return nil, fmt.Errorf("error converting account to api model: %s", err)
+ }
+ mastoAccounts = append(mastoAccounts, mastoAccount)
+ }
+
+ return mastoAccounts, nil
+}
+
+func (p *processor) StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) {
+ l := p.log.WithField("func", "StatusGet")
+
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := >smodel.Status{}
+ if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+ return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+ }
+
+ l.Tracef("going to search for target account %s", targetStatus.AccountID)
+ targetAccount := >smodel.Account{}
+ if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+ return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+ }
+
+ l.Trace("going to get relevant accounts")
+ relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+ }
+
+ l.Trace("going to see if status is visible")
+ visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+ if err != nil {
+ return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+ }
+
+ if !visible {
+ return nil, errors.New("status is not visible")
+ }
+
+ var boostOfStatus *gtsmodel.Status
+ if targetStatus.BoostOfID != "" {
+ boostOfStatus = >smodel.Status{}
+ if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+ return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+ }
+ }
+
+ mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+ }
+
+ return mastoStatus, nil
+
+}
+
+func (p *processor) StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) {
+ l := p.log.WithField("func", "StatusUnfave")
+ l.Tracef("going to search for target status %s", targetStatusID)
+ targetStatus := >smodel.Status{}
+ if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
+ return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err)
+ }
+
+ l.Tracef("going to search for target account %s", targetStatus.AccountID)
+ targetAccount := >smodel.Account{}
+ if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
+ return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)
+ }
+
+ l.Trace("going to get relevant accounts")
+ relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)
+ }
+
+ l.Trace("going to see if status is visible")
+ visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
+ if err != nil {
+ return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)
+ }
+
+ if !visible {
+ return nil, errors.New("status is not visible")
+ }
+
+ // is the status faveable?
+ if !targetStatus.VisibilityAdvanced.Likeable {
+ return nil, errors.New("status is not faveable")
+ }
+
+ // it's visible! it's faveable! so let's unfave the FUCK out of it
+ _, err = p.db.UnfaveStatus(targetStatus, authed.Account.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error unfaveing status: %s", err)
+ }
+
+ var boostOfStatus *gtsmodel.Status
+ if targetStatus.BoostOfID != "" {
+ boostOfStatus = >smodel.Status{}
+ if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil {
+ return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)
+ }
+ }
+
+ mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus)
+ if err != nil {
+ return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)
+ }
+
+ return mastoStatus, nil
+}
diff --git a/internal/oauth/clientstore.go b/internal/oauth/clientstore.go
@@ -30,7 +30,8 @@ type clientStore struct {
db db.DB
}
-func newClientStore(db db.DB) oauth2.ClientStore {
+// NewClientStore returns an implementation of the oauth2 ClientStore interface, using the given db as a storage backend.
+func NewClientStore(db db.DB) oauth2.ClientStore {
pts := &clientStore{
db: db,
}
diff --git a/internal/oauth/clientstore_test.go b/internal/oauth/clientstore_test.go
@@ -15,7 +15,7 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-package oauth
+package oauth_test
import (
"context"
@@ -25,6 +25,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/oauth2/v4/models"
)
@@ -61,7 +62,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {
Database: "postgres",
ApplicationName: "gotosocial",
}
- db, err := db.New(context.Background(), c, log)
+ db, err := db.NewPostgresService(context.Background(), c, log)
if err != nil {
logrus.Panicf("error creating database connection: %s", err)
}
@@ -69,7 +70,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {
suite.db = db
models := []interface{}{
- &Client{},
+ &oauth.Client{},
}
for _, m := range models {
@@ -82,7 +83,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() {
// TearDownTest drops the oauth_clients table and closes the pg connection after each test
func (suite *PgClientStoreTestSuite) TearDownTest() {
models := []interface{}{
- &Client{},
+ &oauth.Client{},
}
for _, m := range models {
if err := suite.db.DropTable(m); err != nil {
@@ -97,7 +98,7 @@ func (suite *PgClientStoreTestSuite) TearDownTest() {
func (suite *PgClientStoreTestSuite) TestClientStoreSetAndGet() {
// set a new client in the store
- cs := newClientStore(suite.db)
+ cs := oauth.NewClientStore(suite.db)
if err := cs.Set(context.Background(), suite.testClientID, models.New(suite.testClientID, suite.testClientSecret, suite.testClientDomain, suite.testClientUserID)); err != nil {
suite.FailNow(err.Error())
}
@@ -115,7 +116,7 @@ func (suite *PgClientStoreTestSuite) TestClientStoreSetAndGet() {
func (suite *PgClientStoreTestSuite) TestClientSetAndDelete() {
// set a new client in the store
- cs := newClientStore(suite.db)
+ cs := oauth.NewClientStore(suite.db)
if err := cs.Set(context.Background(), suite.testClientID, models.New(suite.testClientID, suite.testClientSecret, suite.testClientDomain, suite.testClientUserID)); err != nil {
suite.FailNow(err.Error())
}
diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go
@@ -16,6 +16,6 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-package oauth
+package oauth_test
// TODO: write tests
diff --git a/internal/oauth/server.go b/internal/oauth/server.go
@@ -23,10 +23,8 @@ import (
"fmt"
"net/http"
- "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
"github.com/superseriousbusiness/oauth2/v4"
"github.com/superseriousbusiness/oauth2/v4/errors"
"github.com/superseriousbusiness/oauth2/v4/manage"
@@ -66,94 +64,53 @@ type s struct {
log *logrus.Logger
}
-// Authed wraps an authorized token, application, user, and account.
-// It is used in the functions GetAuthed and MustAuth.
-// Because the user might *not* be authed, any of the fields in this struct
-// might be nil, so make sure to check that when you're using this struct anywhere.
-type Authed struct {
- Token oauth2.TokenInfo
- Application *gtsmodel.Application
- User *gtsmodel.User
- Account *gtsmodel.Account
-}
-
-// GetAuthed is a convenience function for returning an Authed struct from a gin context.
-// In essence, it tries to extract a token, application, user, and account from the context,
-// and then sets them on a struct for convenience.
-//
-// If any are not present in the context, they will be set to nil on the returned Authed struct.
-//
-// If *ALL* are not present, then nil and an error will be returned.
-//
-// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed).
-func GetAuthed(c *gin.Context) (*Authed, error) {
- ctx := c.Copy()
- a := &Authed{}
- var i interface{}
- var ok bool
+// New returns a new oauth server that implements the Server interface
+func New(database db.DB, log *logrus.Logger) Server {
+ ts := newTokenStore(context.Background(), database, log)
+ cs := NewClientStore(database)
- i, ok = ctx.Get(SessionAuthorizedToken)
- if ok {
- parsed, ok := i.(oauth2.TokenInfo)
- if !ok {
- return nil, errors.New("could not parse token from session context")
- }
- a.Token = parsed
+ manager := manage.NewDefaultManager()
+ manager.MapTokenStorage(ts)
+ manager.MapClientStorage(cs)
+ manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
+ sc := &server.Config{
+ TokenType: "Bearer",
+ // Must follow the spec.
+ AllowGetAccessRequest: false,
+ // Support only the non-implicit flow.
+ AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code},
+ // Allow:
+ // - Authorization Code (for first & third parties)
+ // - Client Credentials (for applications)
+ AllowedGrantTypes: []oauth2.GrantType{
+ oauth2.AuthorizationCode,
+ oauth2.ClientCredentials,
+ },
+ AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{oauth2.CodeChallengePlain},
}
- i, ok = ctx.Get(SessionAuthorizedApplication)
- if ok {
- parsed, ok := i.(*gtsmodel.Application)
- if !ok {
- return nil, errors.New("could not parse application from session context")
- }
- a.Application = parsed
- }
+ srv := server.NewServer(sc, manager)
+ srv.SetInternalErrorHandler(func(err error) *errors.Response {
+ log.Errorf("internal oauth error: %s", err)
+ return nil
+ })
- i, ok = ctx.Get(SessionAuthorizedUser)
- if ok {
- parsed, ok := i.(*gtsmodel.User)
- if !ok {
- return nil, errors.New("could not parse user from session context")
- }
- a.User = parsed
- }
+ srv.SetResponseErrorHandler(func(re *errors.Response) {
+ log.Errorf("internal response error: %s", re.Error)
+ })
- i, ok = ctx.Get(SessionAuthorizedAccount)
- if ok {
- parsed, ok := i.(*gtsmodel.Account)
- if !ok {
- return nil, errors.New("could not parse account from session context")
+ srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) {
+ userID := r.FormValue("userid")
+ if userID == "" {
+ return "", errors.New("userid was empty")
}
- a.Account = parsed
- }
-
- if a.Token == nil && a.Application == nil && a.User == nil && a.Account == nil {
- return nil, errors.New("not authorized")
- }
-
- return a, nil
-}
-
-// MustAuth is like GetAuthed, but will fail if one of the requirements is not met.
-func MustAuth(c *gin.Context, requireToken bool, requireApp bool, requireUser bool, requireAccount bool) (*Authed, error) {
- a, err := GetAuthed(c)
- if err != nil {
- return nil, err
- }
- if requireToken && a.Token == nil {
- return nil, errors.New("token not supplied")
- }
- if requireApp && a.Application == nil {
- return nil, errors.New("application not supplied")
- }
- if requireUser && a.User == nil {
- return nil, errors.New("user not supplied")
- }
- if requireAccount && a.Account == nil {
- return nil, errors.New("account not supplied")
+ return userID, nil
+ })
+ srv.SetClientInfoHandler(server.ClientFormHandler)
+ return &s{
+ server: srv,
+ log: log,
}
- return a, nil
}
// HandleTokenRequest wraps the oauth2 library's HandleTokenRequest function
@@ -211,52 +168,3 @@ func (s *s) GenerateUserAccessToken(ti oauth2.TokenInfo, clientSecret string, us
s.log.Tracef("obtained user-level access token: %+v", accessToken)
return accessToken, nil
}
-
-// New returns a new oauth server that implements the Server interface
-func New(database db.DB, log *logrus.Logger) Server {
- ts := newTokenStore(context.Background(), database, log)
- cs := newClientStore(database)
-
- manager := manage.NewDefaultManager()
- manager.MapTokenStorage(ts)
- manager.MapClientStorage(cs)
- manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
- sc := &server.Config{
- TokenType: "Bearer",
- // Must follow the spec.
- AllowGetAccessRequest: false,
- // Support only the non-implicit flow.
- AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code},
- // Allow:
- // - Authorization Code (for first & third parties)
- // - Client Credentials (for applications)
- AllowedGrantTypes: []oauth2.GrantType{
- oauth2.AuthorizationCode,
- oauth2.ClientCredentials,
- },
- AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{oauth2.CodeChallengePlain},
- }
-
- srv := server.NewServer(sc, manager)
- srv.SetInternalErrorHandler(func(err error) *errors.Response {
- log.Errorf("internal oauth error: %s", err)
- return nil
- })
-
- srv.SetResponseErrorHandler(func(re *errors.Response) {
- log.Errorf("internal response error: %s", re.Error)
- })
-
- srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) {
- userID := r.FormValue("userid")
- if userID == "" {
- return "", errors.New("userid was empty")
- }
- return userID, nil
- })
- srv.SetClientInfoHandler(server.ClientFormHandler)
- return &s{
- server: srv,
- log: log,
- }
-}
diff --git a/internal/oauth/tokenstore_test.go b/internal/oauth/tokenstore_test.go
@@ -16,6 +16,6 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-package oauth
+package oauth_test
// TODO: write tests
diff --git a/internal/oauth/util.go b/internal/oauth/util.go
@@ -0,0 +1,86 @@
+package oauth
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/oauth2/v4"
+ "github.com/superseriousbusiness/oauth2/v4/errors"
+)
+
+// Auth wraps an authorized token, application, user, and account.
+// It is used in the functions GetAuthed and MustAuth.
+// Because the user might *not* be authed, any of the fields in this struct
+// might be nil, so make sure to check that when you're using this struct anywhere.
+type Auth struct {
+ Token oauth2.TokenInfo
+ Application *gtsmodel.Application
+ User *gtsmodel.User
+ Account *gtsmodel.Account
+}
+
+// Authed is a convenience function for returning an Authed struct from a gin context.
+// In essence, it tries to extract a token, application, user, and account from the context,
+// and then sets them on a struct for convenience.
+//
+// If any are not present in the context, they will be set to nil on the returned Authed struct.
+//
+// If *ALL* are not present, then nil and an error will be returned.
+//
+// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed).
+// Authed is like GetAuthed, but will fail if one of the requirements is not met.
+func Authed(c *gin.Context, requireToken bool, requireApp bool, requireUser bool, requireAccount bool) (*Auth, error) {
+ ctx := c.Copy()
+ a := &Auth{}
+ var i interface{}
+ var ok bool
+
+ i, ok = ctx.Get(SessionAuthorizedToken)
+ if ok {
+ parsed, ok := i.(oauth2.TokenInfo)
+ if !ok {
+ return nil, errors.New("could not parse token from session context")
+ }
+ a.Token = parsed
+ }
+
+ i, ok = ctx.Get(SessionAuthorizedApplication)
+ if ok {
+ parsed, ok := i.(*gtsmodel.Application)
+ if !ok {
+ return nil, errors.New("could not parse application from session context")
+ }
+ a.Application = parsed
+ }
+
+ i, ok = ctx.Get(SessionAuthorizedUser)
+ if ok {
+ parsed, ok := i.(*gtsmodel.User)
+ if !ok {
+ return nil, errors.New("could not parse user from session context")
+ }
+ a.User = parsed
+ }
+
+ i, ok = ctx.Get(SessionAuthorizedAccount)
+ if ok {
+ parsed, ok := i.(*gtsmodel.Account)
+ if !ok {
+ return nil, errors.New("could not parse account from session context")
+ }
+ a.Account = parsed
+ }
+
+ if requireToken && a.Token == nil {
+ return nil, errors.New("token not supplied")
+ }
+ if requireApp && a.Application == nil {
+ return nil, errors.New("application not supplied")
+ }
+ if requireUser && a.User == nil {
+ return nil, errors.New("user not supplied")
+ }
+ if requireAccount && a.Account == nil {
+ return nil, errors.New("account not supplied")
+ }
+ return a, nil
+}
diff --git a/internal/storage/inmem.go b/internal/storage/inmem.go
@@ -35,7 +35,7 @@ func (s *inMemStorage) RetrieveFileFrom(path string) ([]byte, error) {
l := s.log.WithField("func", "RetrieveFileFrom")
l.Debugf("retrieving from path %s", path)
d, ok := s.stored[path]
- if !ok {
+ if !ok || len(d) == 0 {
return nil, fmt.Errorf("no data found at path %s", path)
}
return d, nil
diff --git a/internal/transport/controller.go b/internal/transport/controller.go
@@ -0,0 +1,71 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package transport
+
+import (
+ "crypto"
+ "fmt"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/go-fed/httpsig"
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+)
+
+// Controller generates transports for use in making federation requests to other servers.
+type Controller interface {
+ NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error)
+}
+
+type controller struct {
+ config *config.Config
+ clock pub.Clock
+ client pub.HttpClient
+ appAgent string
+}
+
+// NewController returns an implementation of the Controller interface for creating new transports
+func NewController(config *config.Config, clock pub.Clock, client pub.HttpClient, log *logrus.Logger) Controller {
+ return &controller{
+ config: config,
+ clock: clock,
+ client: client,
+ appAgent: fmt.Sprintf("%s %s", config.ApplicationName, config.Host),
+ }
+}
+
+// NewTransport returns a new http signature transport with the given public key id (a URL), and the given private key.
+func (c *controller) NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) {
+ prefs := []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512}
+ digestAlgo := httpsig.DigestSha256
+ getHeaders := []string{"(request-target)", "date"}
+ postHeaders := []string{"(request-target)", "date", "digest"}
+
+ getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature)
+ if err != nil {
+ return nil, fmt.Errorf("error creating get signer: %s", err)
+ }
+
+ postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature)
+ if err != nil {
+ return nil, fmt.Errorf("error creating post signer: %s", err)
+ }
+
+ return pub.NewHttpSigTransport(c.client, c.appAgent, c.clock, getSigner, postSigner, pubKeyID, privkey), nil
+}
diff --git a/internal/typeutils/accountable.go b/internal/typeutils/accountable.go
@@ -0,0 +1,101 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils
+
+import "github.com/go-fed/activity/streams/vocab"
+
+// Accountable represents the minimum activitypub interface for representing an 'account'.
+// This interface is fulfilled by: Person, Application, Organization, Service, and Group
+type Accountable interface {
+ withJSONLDId
+ withGetTypeName
+ withPreferredUsername
+ withIcon
+ withDisplayName
+ withImage
+ withSummary
+ withDiscoverable
+ withURL
+ withPublicKey
+ withInbox
+ withOutbox
+ withFollowing
+ withFollowers
+ withFeatured
+}
+
+type withJSONLDId interface {
+ GetJSONLDId() vocab.JSONLDIdProperty
+}
+
+type withGetTypeName interface {
+ GetTypeName() string
+}
+
+type withPreferredUsername interface {
+ GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty
+}
+
+type withIcon interface {
+ GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty
+}
+
+type withDisplayName interface {
+ GetActivityStreamsName() vocab.ActivityStreamsNameProperty
+}
+
+type withImage interface {
+ GetActivityStreamsImage() vocab.ActivityStreamsImageProperty
+}
+
+type withSummary interface {
+ GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty
+}
+
+type withDiscoverable interface {
+ GetTootDiscoverable() vocab.TootDiscoverableProperty
+}
+
+type withURL interface {
+ GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty
+}
+
+type withPublicKey interface {
+ GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
+}
+
+type withInbox interface {
+ GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty
+}
+
+type withOutbox interface {
+ GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty
+}
+
+type withFollowing interface {
+ GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty
+}
+
+type withFollowers interface {
+ GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty
+}
+
+type withFeatured interface {
+ GetTootFeatured() vocab.TootFeaturedProperty
+}
diff --git a/internal/typeutils/asextractionutil.go b/internal/typeutils/asextractionutil.go
@@ -0,0 +1,216 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils
+
+import (
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "net/url"
+
+ "github.com/go-fed/activity/pub"
+)
+
+func extractPreferredUsername(i withPreferredUsername) (string, error) {
+ u := i.GetActivityStreamsPreferredUsername()
+ if u == nil || !u.IsXMLSchemaString() {
+ return "", errors.New("preferredUsername was not a string")
+ }
+ if u.GetXMLSchemaString() == "" {
+ return "", errors.New("preferredUsername was empty")
+ }
+ return u.GetXMLSchemaString(), nil
+}
+
+func extractName(i withDisplayName) (string, error) {
+ nameProp := i.GetActivityStreamsName()
+ if nameProp == nil {
+ return "", errors.New("activityStreamsName not found")
+ }
+
+ // take the first name string we can find
+ for nameIter := nameProp.Begin(); nameIter != nameProp.End(); nameIter = nameIter.Next() {
+ if nameIter.IsXMLSchemaString() && nameIter.GetXMLSchemaString() != "" {
+ return nameIter.GetXMLSchemaString(), nil
+ }
+ }
+
+ return "", errors.New("activityStreamsName not found")
+}
+
+// extractIconURL extracts a URL to a supported image file from something like:
+// "icon": {
+// "mediaType": "image/jpeg",
+// "type": "Image",
+// "url": "http://example.org/path/to/some/file.jpeg"
+// },
+func extractIconURL(i withIcon) (*url.URL, error) {
+ iconProp := i.GetActivityStreamsIcon()
+ if iconProp == nil {
+ return nil, errors.New("icon property was nil")
+ }
+
+ // icon can potentially contain multiple entries, so we iterate through all of them
+ // here in order to find the first one that meets these criteria:
+ // 1. is an image
+ // 2. has a URL so we can grab it
+ for iconIter := iconProp.Begin(); iconIter != iconProp.End(); iconIter = iconIter.Next() {
+ // 1. is an image
+ if !iconIter.IsActivityStreamsImage() {
+ continue
+ }
+ imageValue := iconIter.GetActivityStreamsImage()
+ if imageValue == nil {
+ continue
+ }
+
+ // 2. has a URL so we can grab it
+ url, err := extractURL(imageValue)
+ if err == nil && url != nil {
+ return url, nil
+ }
+ }
+ // if we get to this point we didn't find an icon meeting our criteria :'(
+ return nil, errors.New("could not extract valid image from icon")
+}
+
+// extractImageURL extracts a URL to a supported image file from something like:
+// "image": {
+// "mediaType": "image/jpeg",
+// "type": "Image",
+// "url": "http://example.org/path/to/some/file.jpeg"
+// },
+func extractImageURL(i withImage) (*url.URL, error) {
+ imageProp := i.GetActivityStreamsImage()
+ if imageProp == nil {
+ return nil, errors.New("icon property was nil")
+ }
+
+ // icon can potentially contain multiple entries, so we iterate through all of them
+ // here in order to find the first one that meets these criteria:
+ // 1. is an image
+ // 2. has a URL so we can grab it
+ for imageIter := imageProp.Begin(); imageIter != imageProp.End(); imageIter = imageIter.Next() {
+ // 1. is an image
+ if !imageIter.IsActivityStreamsImage() {
+ continue
+ }
+ imageValue := imageIter.GetActivityStreamsImage()
+ if imageValue == nil {
+ continue
+ }
+
+ // 2. has a URL so we can grab it
+ url, err := extractURL(imageValue)
+ if err == nil && url != nil {
+ return url, nil
+ }
+ }
+ // if we get to this point we didn't find an image meeting our criteria :'(
+ return nil, errors.New("could not extract valid image from image property")
+}
+
+func extractSummary(i withSummary) (string, error) {
+ summaryProp := i.GetActivityStreamsSummary()
+ if summaryProp == nil {
+ return "", errors.New("summary property was nil")
+ }
+
+ for summaryIter := summaryProp.Begin(); summaryIter != summaryProp.End(); summaryIter = summaryIter.Next() {
+ if summaryIter.IsXMLSchemaString() && summaryIter.GetXMLSchemaString() != "" {
+ return summaryIter.GetXMLSchemaString(), nil
+ }
+ }
+
+ return "", errors.New("could not extract summary")
+}
+
+func extractDiscoverable(i withDiscoverable) (bool, error) {
+ if i.GetTootDiscoverable() == nil {
+ return false, errors.New("discoverable was nil")
+ }
+ return i.GetTootDiscoverable().Get(), nil
+}
+
+func extractURL(i withURL) (*url.URL, error) {
+ urlProp := i.GetActivityStreamsUrl()
+ if urlProp == nil {
+ return nil, errors.New("url property was nil")
+ }
+
+ for urlIter := urlProp.Begin(); urlIter != urlProp.End(); urlIter = urlIter.Next() {
+ if urlIter.IsIRI() && urlIter.GetIRI() != nil {
+ return urlIter.GetIRI(), nil
+ }
+ }
+
+ return nil, errors.New("could not extract url")
+}
+
+func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKey, *url.URL, error) {
+ publicKeyProp := i.GetW3IDSecurityV1PublicKey()
+ if publicKeyProp == nil {
+ return nil, nil, errors.New("public key property was nil")
+ }
+
+ for publicKeyIter := publicKeyProp.Begin(); publicKeyIter != publicKeyProp.End(); publicKeyIter = publicKeyIter.Next() {
+ pkey := publicKeyIter.Get()
+ if pkey == nil {
+ continue
+ }
+
+ pkeyID, err := pub.GetId(pkey)
+ if err != nil || pkeyID == nil {
+ continue
+ }
+
+ if pkey.GetW3IDSecurityV1Owner() == nil || pkey.GetW3IDSecurityV1Owner().Get() == nil || pkey.GetW3IDSecurityV1Owner().Get().String() != forOwner.String() {
+ continue
+ }
+
+ if pkey.GetW3IDSecurityV1PublicKeyPem() == nil {
+ continue
+ }
+
+ pkeyPem := pkey.GetW3IDSecurityV1PublicKeyPem().Get()
+ if pkeyPem == "" {
+ continue
+ }
+
+ block, _ := pem.Decode([]byte(pkeyPem))
+ if block == nil || block.Type != "PUBLIC KEY" {
+ return nil, nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type")
+ }
+
+ p, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return nil, nil, fmt.Errorf("could not parse public key from block bytes: %s", err)
+ }
+ if p == nil {
+ return nil, nil, errors.New("returned public key was empty")
+ }
+
+ if publicKey, ok := p.(*rsa.PublicKey); ok {
+ return publicKey, pkeyID, nil
+ }
+ }
+ return nil, nil, errors.New("couldn't find public key")
+}
diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go
@@ -0,0 +1,164 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) {
+ // first check if we actually already know this account
+ uriProp := accountable.GetJSONLDId()
+ if uriProp == nil || !uriProp.IsIRI() {
+ return nil, errors.New("no id property found on person, or id was not an iri")
+ }
+ uri := uriProp.GetIRI()
+
+ acct := >smodel.Account{}
+ err := c.db.GetWhere("uri", uri.String(), acct)
+ if err == nil {
+ // we already know this account so we can skip generating it
+ return acct, nil
+ }
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ // we don't know the account and there's been a real error
+ return nil, fmt.Errorf("error getting account with uri %s from the database: %s", uri.String(), err)
+ }
+
+ // we don't know the account so we need to generate it from the person -- at least we already have the URI!
+ acct = >smodel.Account{}
+ acct.URI = uri.String()
+
+ // Username aka preferredUsername
+ // We need this one so bail if it's not set.
+ username, err := extractPreferredUsername(accountable)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't extract username: %s", err)
+ }
+ acct.Username = username
+
+ // Domain
+ acct.Domain = uri.Host
+
+ // avatar aka icon
+ // if this one isn't extractable in a format we recognise we'll just skip it
+ if avatarURL, err := extractIconURL(accountable); err == nil {
+ acct.AvatarRemoteURL = avatarURL.String()
+ }
+
+ // header aka image
+ // if this one isn't extractable in a format we recognise we'll just skip it
+ if headerURL, err := extractImageURL(accountable); err == nil {
+ acct.HeaderRemoteURL = headerURL.String()
+ }
+
+ // display name aka name
+ // we default to the username, but take the more nuanced name property if it exists
+ acct.DisplayName = username
+ if displayName, err := extractName(accountable); err == nil {
+ acct.DisplayName = displayName
+ }
+
+ // TODO: fields aka attachment array
+
+ // note aka summary
+ note, err := extractSummary(accountable)
+ if err == nil && note != "" {
+ acct.Note = note
+ }
+
+ // check for bot and actor type
+ switch gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) {
+ case gtsmodel.ActivityStreamsPerson, gtsmodel.ActivityStreamsGroup, gtsmodel.ActivityStreamsOrganization:
+ // people, groups, and organizations aren't bots
+ acct.Bot = false
+ // apps and services are
+ case gtsmodel.ActivityStreamsApplication, gtsmodel.ActivityStreamsService:
+ acct.Bot = true
+ default:
+ // we don't know what this is!
+ return nil, fmt.Errorf("type name %s not recognised or not convertible to gtsmodel.ActivityStreamsActor", accountable.GetTypeName())
+ }
+ acct.ActorType = gtsmodel.ActivityStreamsActor(accountable.GetTypeName())
+
+ // TODO: locked aka manuallyApprovesFollowers
+
+ // discoverable
+ // default to false -- take custom value if it's set though
+ acct.Discoverable = false
+ discoverable, err := extractDiscoverable(accountable)
+ if err == nil {
+ acct.Discoverable = discoverable
+ }
+
+ // url property
+ url, err := extractURL(accountable)
+ if err != nil {
+ return nil, fmt.Errorf("could not extract url for person with id %s: %s", uri.String(), err)
+ }
+ acct.URL = url.String()
+
+ // InboxURI
+ if accountable.GetActivityStreamsInbox() == nil || accountable.GetActivityStreamsInbox().GetIRI() == nil {
+ return nil, fmt.Errorf("person with id %s had no inbox uri", uri.String())
+ }
+ acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String()
+
+ // OutboxURI
+ if accountable.GetActivityStreamsOutbox() == nil || accountable.GetActivityStreamsOutbox().GetIRI() == nil {
+ return nil, fmt.Errorf("person with id %s had no outbox uri", uri.String())
+ }
+ acct.OutboxURI = accountable.GetActivityStreamsOutbox().GetIRI().String()
+
+ // FollowingURI
+ if accountable.GetActivityStreamsFollowing() == nil || accountable.GetActivityStreamsFollowing().GetIRI() == nil {
+ return nil, fmt.Errorf("person with id %s had no following uri", uri.String())
+ }
+ acct.FollowingURI = accountable.GetActivityStreamsFollowing().GetIRI().String()
+
+ // FollowersURI
+ if accountable.GetActivityStreamsFollowers() == nil || accountable.GetActivityStreamsFollowers().GetIRI() == nil {
+ return nil, fmt.Errorf("person with id %s had no followers uri", uri.String())
+ }
+ acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String()
+
+ // FeaturedURI
+ // very much optional
+ if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil {
+ acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String()
+ }
+
+ // TODO: FeaturedTagsURI
+
+ // TODO: alsoKnownAs
+
+ // publicKey
+ pkey, pkeyURL, err := extractPublicKeyForOwner(accountable, uri)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't get public key for person %s: %s", uri.String(), err)
+ }
+ acct.PublicKey = pkey
+ acct.PublicKeyURI = pkeyURL.String()
+
+ return acct, nil
+}
diff --git a/internal/typeutils/astointernal_test.go b/internal/typeutils/astointernal_test.go
@@ -0,0 +1,206 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/go-fed/activity/streams"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ASToInternalTestSuite struct {
+ ConverterStandardTestSuite
+}
+
+const (
+ gargronAsActivityJson = `{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "toot": "http://joinmastodon.org/ns#",
+ "featured": {
+ "@id": "toot:featured",
+ "@type": "@id"
+ },
+ "featuredTags": {
+ "@id": "toot:featuredTags",
+ "@type": "@id"
+ },
+ "alsoKnownAs": {
+ "@id": "as:alsoKnownAs",
+ "@type": "@id"
+ },
+ "movedTo": {
+ "@id": "as:movedTo",
+ "@type": "@id"
+ },
+ "schema": "http://schema.org#",
+ "PropertyValue": "schema:PropertyValue",
+ "value": "schema:value",
+ "IdentityProof": "toot:IdentityProof",
+ "discoverable": "toot:discoverable",
+ "Device": "toot:Device",
+ "Ed25519Signature": "toot:Ed25519Signature",
+ "Ed25519Key": "toot:Ed25519Key",
+ "Curve25519Key": "toot:Curve25519Key",
+ "EncryptedMessage": "toot:EncryptedMessage",
+ "publicKeyBase64": "toot:publicKeyBase64",
+ "deviceId": "toot:deviceId",
+ "claim": {
+ "@type": "@id",
+ "@id": "toot:claim"
+ },
+ "fingerprintKey": {
+ "@type": "@id",
+ "@id": "toot:fingerprintKey"
+ },
+ "identityKey": {
+ "@type": "@id",
+ "@id": "toot:identityKey"
+ },
+ "devices": {
+ "@type": "@id",
+ "@id": "toot:devices"
+ },
+ "messageFranking": "toot:messageFranking",
+ "messageType": "toot:messageType",
+ "cipherText": "toot:cipherText",
+ "suspended": "toot:suspended",
+ "focalPoint": {
+ "@container": "@list",
+ "@id": "toot:focalPoint"
+ }
+ }
+ ],
+ "id": "https://mastodon.social/users/Gargron",
+ "type": "Person",
+ "following": "https://mastodon.social/users/Gargron/following",
+ "followers": "https://mastodon.social/users/Gargron/followers",
+ "inbox": "https://mastodon.social/users/Gargron/inbox",
+ "outbox": "https://mastodon.social/users/Gargron/outbox",
+ "featured": "https://mastodon.social/users/Gargron/collections/featured",
+ "featuredTags": "https://mastodon.social/users/Gargron/collections/tags",
+ "preferredUsername": "Gargron",
+ "name": "Eugen",
+ "summary": "<p>Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.</p>",
+ "url": "https://mastodon.social/@Gargron",
+ "manuallyApprovesFollowers": false,
+ "discoverable": true,
+ "devices": "https://mastodon.social/users/Gargron/collections/devices",
+ "alsoKnownAs": [
+ "https://tooting.ai/users/Gargron"
+ ],
+ "publicKey": {
+ "id": "https://mastodon.social/users/Gargron#main-key",
+ "owner": "https://mastodon.social/users/Gargron",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n"
+ },
+ "tag": [],
+ "attachment": [
+ {
+ "type": "PropertyValue",
+ "name": "Patreon",
+ "value": "<a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>"
+ },
+ {
+ "type": "PropertyValue",
+ "name": "Homepage",
+ "value": "<a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span></a>"
+ },
+ {
+ "type": "IdentityProof",
+ "name": "gargron",
+ "signatureAlgorithm": "keybase",
+ "signatureValue": "5cfc20c7018f2beefb42a68836da59a792e55daa4d118498c9b1898de7e845690f"
+ }
+ ],
+ "endpoints": {
+ "sharedInbox": "https://mastodon.social/inbox"
+ },
+ "icon": {
+ "type": "Image",
+ "mediaType": "image/jpeg",
+ "url": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg"
+ },
+ "image": {
+ "type": "Image",
+ "mediaType": "image/png",
+ "url": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png"
+ }
+ }`
+)
+
+func (suite *ASToInternalTestSuite) SetupSuite() {
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.accounts = testrig.NewTestAccounts()
+ suite.people = testrig.NewTestFediPeople()
+ suite.typeconverter = typeutils.NewConverter(suite.config, suite.db)
+}
+
+func (suite *ASToInternalTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+}
+
+func (suite *ASToInternalTestSuite) TestParsePerson() {
+
+ testPerson := suite.people["new_person_1"]
+
+ acct, err := suite.typeconverter.ASRepresentationToAccount(testPerson)
+ assert.NoError(suite.T(), err)
+
+ fmt.Printf("%+v", acct)
+ // TODO: write assertions here, rn we're just eyeballing the output
+}
+
+func (suite *ASToInternalTestSuite) TestParseGargron() {
+ m := make(map[string]interface{})
+ err := json.Unmarshal([]byte(gargronAsActivityJson), &m)
+ assert.NoError(suite.T(), err)
+
+ t, err := streams.ToType(context.Background(), m)
+ assert.NoError(suite.T(), err)
+
+ rep, ok := t.(typeutils.Accountable)
+ assert.True(suite.T(), ok)
+
+ acct, err := suite.typeconverter.ASRepresentationToAccount(rep)
+ assert.NoError(suite.T(), err)
+
+ fmt.Printf("%+v", acct)
+ // TODO: write assertions here, rn we're just eyeballing the output
+}
+
+func (suite *ASToInternalTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+}
+
+func TestASToInternalTestSuite(t *testing.T) {
+ suite.Run(t, new(ASToInternalTestSuite))
+}
diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go
@@ -0,0 +1,113 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils
+
+import (
+ "github.com/go-fed/activity/streams/vocab"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// TypeConverter is an interface for the common action of converting between apimodule (frontend, serializable) models,
+// internal gts models used in the database, and activitypub models used in federation.
+//
+// It requires access to the database because many of the conversions require pulling out database entries and counting them etc.
+// That said, it *absolutely should not* manipulate database entries in any way, only examine them.
+type TypeConverter interface {
+ /*
+ INTERNAL (gts) MODEL TO FRONTEND (mastodon) MODEL
+ */
+
+ // AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error
+ // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields,
+ // so serve it only to an authorized user who should have permission to see it.
+ AccountToMastoSensitive(account *gtsmodel.Account) (*model.Account, error)
+
+ // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error
+ // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields.
+ // In other words, this is the public record that the server has of an account.
+ AccountToMastoPublic(account *gtsmodel.Account) (*model.Account, error)
+
+ // AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error
+ // if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields
+ // (such as client id and client secret), so serve it only to an authorized user who should have permission to see it.
+ AppToMastoSensitive(application *gtsmodel.Application) (*model.Application, error)
+
+ // AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error
+ // if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive
+ // fields sanitized so that it can be served to non-authorized accounts without revealing any private information.
+ AppToMastoPublic(application *gtsmodel.Application) (*model.Application, error)
+
+ // AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API.
+ AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (model.Attachment, error)
+
+ // MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API.
+ MentionToMasto(m *gtsmodel.Mention) (model.Mention, error)
+
+ // EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API.
+ EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error)
+
+ // TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API.
+ TagToMasto(t *gtsmodel.Tag) (model.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) (*model.Status, error)
+
+ // VisToMasto converts a gts visibility into its mastodon equivalent
+ VisToMasto(m gtsmodel.Visibility) model.Visibility
+
+ /*
+ FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL
+ */
+
+ // MastoVisToVis converts a mastodon visibility into its gts equivalent.
+ MastoVisToVis(m model.Visibility) gtsmodel.Visibility
+
+ /*
+ ACTIVITYSTREAMS MODEL TO INTERNAL (gts) MODEL
+ */
+
+ // ASPersonToAccount converts a remote account/person/application representation into a gts model account
+ ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error)
+
+ /*
+ INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL
+ */
+
+ // AccountToAS converts a gts model account into an activity streams person, suitable for federation
+ AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error)
+
+ // StatusToAS converts a gts model status into an activity streams note, suitable for federation
+ StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error)
+}
+
+type converter struct {
+ config *config.Config
+ db db.DB
+}
+
+// NewConverter returns a new Converter
+func NewConverter(config *config.Config, db db.DB) TypeConverter {
+ return &converter{
+ config: config,
+ db: db,
+ }
+}
diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go
@@ -0,0 +1,40 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils_test
+
+import (
+ "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/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// nolint
+type ConverterStandardTestSuite struct {
+ suite.Suite
+ config *config.Config
+ db db.DB
+ log *logrus.Logger
+ accounts map[string]*gtsmodel.Account
+ people map[string]typeutils.Accountable
+
+ typeconverter typeutils.TypeConverter
+}
diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go
@@ -0,0 +1,39 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils
+
+import (
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// MastoVisToVis converts a mastodon visibility into its gts equivalent.
+func (c *converter) MastoVisToVis(m model.Visibility) gtsmodel.Visibility {
+ switch m {
+ case model.VisibilityPublic:
+ return gtsmodel.VisibilityPublic
+ case model.VisibilityUnlisted:
+ return gtsmodel.VisibilityUnlocked
+ case model.VisibilityPrivate:
+ return gtsmodel.VisibilityFollowersOnly
+ case model.VisibilityDirect:
+ return gtsmodel.VisibilityDirect
+ }
+ return ""
+}
diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go
@@ -0,0 +1,260 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils
+
+import (
+ "crypto/x509"
+ "encoding/pem"
+ "net/url"
+
+ "github.com/go-fed/activity/streams"
+ "github.com/go-fed/activity/streams/vocab"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// Converts a gts model account into an Activity Streams person type, following
+// the spec laid out for mastodon here: https://docs.joinmastodon.org/spec/activitypub/
+func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) {
+ person := streams.NewActivityStreamsPerson()
+
+ // id should be the activitypub URI of this user
+ // something like https://example.org/users/example_user
+ profileIDURI, err := url.Parse(a.URI)
+ if err != nil {
+ return nil, err
+ }
+ idProp := streams.NewJSONLDIdProperty()
+ idProp.SetIRI(profileIDURI)
+ person.SetJSONLDId(idProp)
+
+ // following
+ // The URI for retrieving a list of accounts this user is following
+ followingURI, err := url.Parse(a.FollowingURI)
+ if err != nil {
+ return nil, err
+ }
+ followingProp := streams.NewActivityStreamsFollowingProperty()
+ followingProp.SetIRI(followingURI)
+ person.SetActivityStreamsFollowing(followingProp)
+
+ // followers
+ // The URI for retrieving a list of this user's followers
+ followersURI, err := url.Parse(a.FollowersURI)
+ if err != nil {
+ return nil, err
+ }
+ followersProp := streams.NewActivityStreamsFollowersProperty()
+ followersProp.SetIRI(followersURI)
+ person.SetActivityStreamsFollowers(followersProp)
+
+ // inbox
+ // the activitypub inbox of this user for accepting messages
+ inboxURI, err := url.Parse(a.InboxURI)
+ if err != nil {
+ return nil, err
+ }
+ inboxProp := streams.NewActivityStreamsInboxProperty()
+ inboxProp.SetIRI(inboxURI)
+ person.SetActivityStreamsInbox(inboxProp)
+
+ // outbox
+ // the activitypub outbox of this user for serving messages
+ outboxURI, err := url.Parse(a.OutboxURI)
+ if err != nil {
+ return nil, err
+ }
+ outboxProp := streams.NewActivityStreamsOutboxProperty()
+ outboxProp.SetIRI(outboxURI)
+ person.SetActivityStreamsOutbox(outboxProp)
+
+ // featured posts
+ // Pinned posts.
+ featuredURI, err := url.Parse(a.FeaturedCollectionURI)
+ if err != nil {
+ return nil, err
+ }
+ featuredProp := streams.NewTootFeaturedProperty()
+ featuredProp.SetIRI(featuredURI)
+ person.SetTootFeatured(featuredProp)
+
+ // featuredTags
+ // NOT IMPLEMENTED
+
+ // preferredUsername
+ // Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI.
+ preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty()
+ preferredUsernameProp.SetXMLSchemaString(a.Username)
+ person.SetActivityStreamsPreferredUsername(preferredUsernameProp)
+
+ // name
+ // Used as profile display name.
+ nameProp := streams.NewActivityStreamsNameProperty()
+ if a.Username != "" {
+ nameProp.AppendXMLSchemaString(a.DisplayName)
+ } else {
+ nameProp.AppendXMLSchemaString(a.Username)
+ }
+ person.SetActivityStreamsName(nameProp)
+
+ // summary
+ // Used as profile bio.
+ if a.Note != "" {
+ summaryProp := streams.NewActivityStreamsSummaryProperty()
+ summaryProp.AppendXMLSchemaString(a.Note)
+ person.SetActivityStreamsSummary(summaryProp)
+ }
+
+ // url
+ // Used as profile link.
+ profileURL, err := url.Parse(a.URL)
+ if err != nil {
+ return nil, err
+ }
+ urlProp := streams.NewActivityStreamsUrlProperty()
+ urlProp.AppendIRI(profileURL)
+ person.SetActivityStreamsUrl(urlProp)
+
+ // manuallyApprovesFollowers
+ // Will be shown as a locked account.
+ // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool
+
+ // discoverable
+ // Will be shown in the profile directory.
+ discoverableProp := streams.NewTootDiscoverableProperty()
+ discoverableProp.Set(a.Discoverable)
+ person.SetTootDiscoverable(discoverableProp)
+
+ // devices
+ // NOT IMPLEMENTED, probably won't implement
+
+ // alsoKnownAs
+ // Required for Move activity.
+ // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool
+
+ // publicKey
+ // Required for signatures.
+ publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
+
+ // create the public key
+ publicKey := streams.NewW3IDSecurityV1PublicKey()
+
+ // set ID for the public key
+ publicKeyIDProp := streams.NewJSONLDIdProperty()
+ publicKeyURI, err := url.Parse(a.PublicKeyURI)
+ if err != nil {
+ return nil, err
+ }
+ publicKeyIDProp.SetIRI(publicKeyURI)
+ publicKey.SetJSONLDId(publicKeyIDProp)
+
+ // set owner for the public key
+ publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty()
+ publicKeyOwnerProp.SetIRI(profileIDURI)
+ publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp)
+
+ // set the pem key itself
+ encodedPublicKey, err := x509.MarshalPKIXPublicKey(a.PublicKey)
+ if err != nil {
+ return nil, err
+ }
+ publicKeyBytes := pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: encodedPublicKey,
+ })
+ publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
+ publicKeyPEMProp.Set(string(publicKeyBytes))
+ publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp)
+
+ // append the public key to the public key property
+ publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey)
+
+ // set the public key property on the Person
+ person.SetW3IDSecurityV1PublicKey(publicKeyProp)
+
+ // tag
+ // TODO: Any tags used in the summary of this profile
+
+ // attachment
+ // Used for profile fields.
+ // TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue
+
+ // endpoints
+ // NOT IMPLEMENTED -- this is for shared inbox which we don't use
+
+ // icon
+ // Used as profile avatar.
+ if a.AvatarMediaAttachmentID != "" {
+ iconProperty := streams.NewActivityStreamsIconProperty()
+
+ iconImage := streams.NewActivityStreamsImage()
+
+ avatar := >smodel.MediaAttachment{}
+ if err := c.db.GetByID(a.AvatarMediaAttachmentID, avatar); err != nil {
+ return nil, err
+ }
+
+ mediaType := streams.NewActivityStreamsMediaTypeProperty()
+ mediaType.Set(avatar.File.ContentType)
+ iconImage.SetActivityStreamsMediaType(mediaType)
+
+ avatarURLProperty := streams.NewActivityStreamsUrlProperty()
+ avatarURL, err := url.Parse(avatar.URL)
+ if err != nil {
+ return nil, err
+ }
+ avatarURLProperty.AppendIRI(avatarURL)
+ iconImage.SetActivityStreamsUrl(avatarURLProperty)
+
+ iconProperty.AppendActivityStreamsImage(iconImage)
+ person.SetActivityStreamsIcon(iconProperty)
+ }
+
+ // image
+ // Used as profile header.
+ if a.HeaderMediaAttachmentID != "" {
+ headerProperty := streams.NewActivityStreamsImageProperty()
+
+ headerImage := streams.NewActivityStreamsImage()
+
+ header := >smodel.MediaAttachment{}
+ if err := c.db.GetByID(a.HeaderMediaAttachmentID, header); err != nil {
+ return nil, err
+ }
+
+ mediaType := streams.NewActivityStreamsMediaTypeProperty()
+ mediaType.Set(header.File.ContentType)
+ headerImage.SetActivityStreamsMediaType(mediaType)
+
+ headerURLProperty := streams.NewActivityStreamsUrlProperty()
+ headerURL, err := url.Parse(header.URL)
+ if err != nil {
+ return nil, err
+ }
+ headerURLProperty.AppendIRI(headerURL)
+ headerImage.SetActivityStreamsUrl(headerURLProperty)
+
+ headerProperty.AppendActivityStreamsImage(headerImage)
+ }
+
+ return person, nil
+}
+
+func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) {
+ return nil, nil
+}
diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go
@@ -0,0 +1,76 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/go-fed/activity/streams"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type InternalToASTestSuite struct {
+ ConverterStandardTestSuite
+}
+
+// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
+func (suite *InternalToASTestSuite) SetupSuite() {
+ // setup standard items
+ suite.config = testrig.NewTestConfig()
+ suite.db = testrig.NewTestDB()
+ suite.log = testrig.NewTestLog()
+ suite.accounts = testrig.NewTestAccounts()
+ suite.people = testrig.NewTestFediPeople()
+ suite.typeconverter = typeutils.NewConverter(suite.config, suite.db)
+}
+
+func (suite *InternalToASTestSuite) SetupTest() {
+ testrig.StandardDBSetup(suite.db)
+}
+
+// TearDownTest drops tables to make sure there's no data in the db
+func (suite *InternalToASTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+}
+
+func (suite *InternalToASTestSuite) TestAccountToAS() {
+ testAccount := suite.accounts["local_account_1"] // take zork for this test
+
+ asPerson, err := suite.typeconverter.AccountToAS(testAccount)
+ assert.NoError(suite.T(), err)
+
+ ser, err := streams.Serialize(asPerson)
+ assert.NoError(suite.T(), err)
+
+ bytes, err := json.Marshal(ser)
+ assert.NoError(suite.T(), err)
+
+ fmt.Println(string(bytes))
+ // TODO: write assertions here, rn we're just eyeballing the output
+}
+
+func TestInternalToASTestSuite(t *testing.T) {
+ suite.Run(t, new(InternalToASTestSuite))
+}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
@@ -0,0 +1,505 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package typeutils
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*model.Account, error) {
+ // we can build this sensitive account easily by first getting the public account....
+ mastoAccount, err := c.AccountToMastoPublic(a)
+ if err != nil {
+ return nil, err
+ }
+
+ // then adding the Source object to it...
+
+ // check pending follow requests aimed at this account
+ fr := []gtsmodel.FollowRequest{}
+ if err := c.db.GetFollowRequestsForAccountID(a.ID, &fr); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return nil, fmt.Errorf("error getting follow requests: %s", err)
+ }
+ }
+ var frc int
+ if fr != nil {
+ frc = len(fr)
+ }
+
+ mastoAccount.Source = &model.Source{
+ Privacy: c.VisToMasto(a.Privacy),
+ Sensitive: a.Sensitive,
+ Language: a.Language,
+ Note: a.Note,
+ Fields: mastoAccount.Fields,
+ FollowRequestsCount: frc,
+ }
+
+ return mastoAccount, nil
+}
+
+func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, error) {
+ // count followers
+ followers := []gtsmodel.Follow{}
+ if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return nil, fmt.Errorf("error getting followers: %s", err)
+ }
+ }
+ var followersCount int
+ if followers != nil {
+ followersCount = len(followers)
+ }
+
+ // count following
+ following := []gtsmodel.Follow{}
+ if err := c.db.GetFollowingByAccountID(a.ID, &following); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return nil, fmt.Errorf("error getting following: %s", err)
+ }
+ }
+ var followingCount int
+ if following != nil {
+ followingCount = len(following)
+ }
+
+ // count statuses
+ statuses := []gtsmodel.Status{}
+ if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return nil, fmt.Errorf("error getting last statuses: %s", err)
+ }
+ }
+ var statusesCount int
+ if statuses != nil {
+ statusesCount = len(statuses)
+ }
+
+ // check when the last status was
+ lastStatus := >smodel.Status{}
+ if err := c.db.GetLastStatusForAccountID(a.ID, lastStatus); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return nil, fmt.Errorf("error getting last status: %s", err)
+ }
+ }
+ var lastStatusAt string
+ if lastStatus != nil {
+ lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339)
+ }
+
+ // build the avatar and header URLs
+ avi := >smodel.MediaAttachment{}
+ if err := c.db.GetAvatarForAccountID(avi, a.ID); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return nil, fmt.Errorf("error getting avatar: %s", err)
+ }
+ }
+ aviURL := avi.URL
+ aviURLStatic := avi.Thumbnail.URL
+
+ header := >smodel.MediaAttachment{}
+ if err := c.db.GetHeaderForAccountID(header, a.ID); err != nil {
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ return nil, fmt.Errorf("error getting header: %s", err)
+ }
+ }
+ headerURL := header.URL
+ headerURLStatic := header.Thumbnail.URL
+
+ // get the fields set on this account
+ fields := []model.Field{}
+ for _, f := range a.Fields {
+ mField := model.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 &model.Account{
+ ID: a.ID,
+ Username: a.Username,
+ Acct: acct,
+ DisplayName: a.DisplayName,
+ Locked: a.Locked,
+ Bot: a.Bot,
+ CreatedAt: a.CreatedAt.Format(time.RFC3339),
+ Note: a.Note,
+ URL: a.URL,
+ Avatar: aviURL,
+ AvatarStatic: aviURLStatic,
+ Header: headerURL,
+ HeaderStatic: headerURLStatic,
+ FollowersCount: followersCount,
+ FollowingCount: followingCount,
+ StatusesCount: statusesCount,
+ LastStatusAt: lastStatusAt,
+ Emojis: nil, // TODO: implement this
+ Fields: fields,
+ }, nil
+}
+
+func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*model.Application, error) {
+ return &model.Application{
+ ID: a.ID,
+ Name: a.Name,
+ Website: a.Website,
+ RedirectURI: a.RedirectURI,
+ ClientID: a.ClientID,
+ ClientSecret: a.ClientSecret,
+ VapidKey: a.VapidKey,
+ }, nil
+}
+
+func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*model.Application, error) {
+ return &model.Application{
+ Name: a.Name,
+ Website: a.Website,
+ }, nil
+}
+
+func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (model.Attachment, error) {
+ return model.Attachment{
+ ID: a.ID,
+ Type: string(a.Type),
+ URL: a.URL,
+ PreviewURL: a.Thumbnail.URL,
+ RemoteURL: a.RemoteURL,
+ PreviewRemoteURL: a.Thumbnail.RemoteURL,
+ Meta: model.MediaMeta{
+ Original: model.MediaDimensions{
+ Width: a.FileMeta.Original.Width,
+ Height: a.FileMeta.Original.Height,
+ Size: fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height),
+ Aspect: float32(a.FileMeta.Original.Aspect),
+ },
+ Small: model.MediaDimensions{
+ Width: a.FileMeta.Small.Width,
+ Height: a.FileMeta.Small.Height,
+ Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height),
+ Aspect: float32(a.FileMeta.Small.Aspect),
+ },
+ Focus: model.MediaFocus{
+ X: a.FileMeta.Focus.X,
+ Y: a.FileMeta.Focus.Y,
+ },
+ },
+ Description: a.Description,
+ Blurhash: a.Blurhash,
+ }, nil
+}
+
+func (c *converter) MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) {
+ target := >smodel.Account{}
+ if err := c.db.GetByID(m.TargetAccountID, target); err != nil {
+ return model.Mention{}, err
+ }
+
+ var local bool
+ if target.Domain == "" {
+ local = true
+ }
+
+ var acct string
+ if local {
+ acct = fmt.Sprintf("@%s", target.Username)
+ } else {
+ acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain)
+ }
+
+ return model.Mention{
+ ID: target.ID,
+ Username: target.Username,
+ URL: target.URL,
+ Acct: acct,
+ }, nil
+}
+
+func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) {
+ return model.Emoji{
+ Shortcode: e.Shortcode,
+ URL: e.ImageURL,
+ StaticURL: e.ImageStaticURL,
+ VisibleInPicker: e.VisibleInPicker,
+ Category: e.CategoryID,
+ }, nil
+}
+
+func (c *converter) TagToMasto(t *gtsmodel.Tag) (model.Tag, error) {
+ tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name)
+
+ return model.Tag{
+ Name: t.Name,
+ URL: tagURL, // we don't serve URLs with collections of tagged statuses (FOR NOW) so this is purely for mastodon compatibility ¯\_(ツ)_/¯
+ }, nil
+}
+
+func (c *converter) StatusToMasto(
+ s *gtsmodel.Status,
+ targetAccount *gtsmodel.Account,
+ requestingAccount *gtsmodel.Account,
+ boostOfAccount *gtsmodel.Account,
+ replyToAccount *gtsmodel.Account,
+ reblogOfStatus *gtsmodel.Status) (*model.Status, error) {
+
+ repliesCount, err := c.db.GetReplyCountForStatus(s)
+ if err != nil {
+ return nil, fmt.Errorf("error counting replies: %s", err)
+ }
+
+ reblogsCount, err := c.db.GetReblogCountForStatus(s)
+ if err != nil {
+ return nil, fmt.Errorf("error counting reblogs: %s", err)
+ }
+
+ favesCount, err := c.db.GetFaveCountForStatus(s)
+ if err != nil {
+ return nil, fmt.Errorf("error counting faves: %s", err)
+ }
+
+ var faved bool
+ var reblogged bool
+ var bookmarked bool
+ var pinned bool
+ var muted bool
+
+ // requestingAccount will be nil for public requests without auth
+ // But if it's not nil, we can also get information about the requestingAccount's interaction with this status
+ if requestingAccount != nil {
+ faved, err = c.db.StatusFavedBy(s, requestingAccount.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error checking if requesting account has faved status: %s", err)
+ }
+
+ reblogged, err = c.db.StatusRebloggedBy(s, requestingAccount.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error checking if requesting account has reblogged status: %s", err)
+ }
+
+ muted, err = c.db.StatusMutedBy(s, requestingAccount.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err)
+ }
+
+ bookmarked, err = c.db.StatusBookmarkedBy(s, requestingAccount.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err)
+ }
+
+ pinned, err = c.db.StatusPinnedBy(s, requestingAccount.ID)
+ if err != nil {
+ return nil, fmt.Errorf("error checking if requesting account has pinned status: %s", err)
+ }
+ }
+
+ var mastoRebloggedStatus *model.Status // TODO
+
+ var mastoApplication *model.Application
+ if s.CreatedWithApplicationID != "" {
+ gtsApplication := >smodel.Application{}
+ if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil {
+ return nil, fmt.Errorf("error fetching application used to create status: %s", err)
+ }
+ mastoApplication, err = c.AppToMastoPublic(gtsApplication)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing application used to create status: %s", err)
+ }
+ }
+
+ mastoTargetAccount, err := c.AccountToMastoPublic(targetAccount)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing account of status author: %s", err)
+ }
+
+ mastoAttachments := []model.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 := []model.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 := []model.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 := []model.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 *model.Card
+ var mastoPoll *model.Poll
+
+ return &model.Status{
+ ID: s.ID,
+ CreatedAt: s.CreatedAt.Format(time.RFC3339),
+ InReplyToID: s.InReplyToID,
+ InReplyToAccountID: s.InReplyToAccountID,
+ Sensitive: s.Sensitive,
+ SpoilerText: s.ContentWarning,
+ Visibility: c.VisToMasto(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
+}
+
+// VisToMasto converts a gts visibility into its mastodon equivalent
+func (c *converter) VisToMasto(m gtsmodel.Visibility) model.Visibility {
+ switch m {
+ case gtsmodel.VisibilityPublic:
+ return model.VisibilityPublic
+ case gtsmodel.VisibilityUnlocked:
+ return model.VisibilityUnlisted
+ case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
+ return model.VisibilityPrivate
+ case gtsmodel.VisibilityDirect:
+ return model.VisibilityDirect
+ }
+ return ""
+}
diff --git a/internal/util/parse.go b/internal/util/parse.go
@@ -1,96 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package util
-
-import (
- "fmt"
-
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
- mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
-)
-
-// URIs contains a bunch of URIs and URLs for a user, host, account, etc.
-type URIs struct {
- HostURL string
- UserURL string
- StatusesURL string
-
- UserURI string
- StatusesURI string
- InboxURI string
- OutboxURI string
- FollowersURI string
- CollectionURI string
-}
-
-// GenerateURIs throws together a bunch of URIs for the given username, with the given protocol and host.
-func GenerateURIs(username string, protocol string, host string) *URIs {
- hostURL := fmt.Sprintf("%s://%s", protocol, host)
- userURL := fmt.Sprintf("%s/@%s", hostURL, username)
- statusesURL := fmt.Sprintf("%s/statuses", userURL)
-
- userURI := fmt.Sprintf("%s/users/%s", hostURL, username)
- statusesURI := fmt.Sprintf("%s/statuses", userURI)
- inboxURI := fmt.Sprintf("%s/inbox", userURI)
- outboxURI := fmt.Sprintf("%s/outbox", userURI)
- followersURI := fmt.Sprintf("%s/followers", userURI)
- collectionURI := fmt.Sprintf("%s/collections/featured", userURI)
- return &URIs{
- HostURL: hostURL,
- UserURL: userURL,
- StatusesURL: statusesURL,
-
- UserURI: userURI,
- StatusesURI: statusesURI,
- InboxURI: inboxURI,
- OutboxURI: outboxURI,
- FollowersURI: followersURI,
- CollectionURI: collectionURI,
- }
-}
-
-// ParseGTSVisFromMastoVis converts a mastodon visibility into its gts equivalent.
-func ParseGTSVisFromMastoVis(m mastotypes.Visibility) gtsmodel.Visibility {
- switch m {
- case mastotypes.VisibilityPublic:
- return gtsmodel.VisibilityPublic
- case mastotypes.VisibilityUnlisted:
- return gtsmodel.VisibilityUnlocked
- case mastotypes.VisibilityPrivate:
- return gtsmodel.VisibilityFollowersOnly
- case mastotypes.VisibilityDirect:
- return gtsmodel.VisibilityDirect
- }
- return ""
-}
-
-// ParseMastoVisFromGTSVis converts a gts visibility into its mastodon equivalent
-func ParseMastoVisFromGTSVis(m gtsmodel.Visibility) mastotypes.Visibility {
- switch m {
- case gtsmodel.VisibilityPublic:
- return mastotypes.VisibilityPublic
- case gtsmodel.VisibilityUnlocked:
- return mastotypes.VisibilityUnlisted
- case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
- return mastotypes.VisibilityPrivate
- case gtsmodel.VisibilityDirect:
- return mastotypes.VisibilityDirect
- }
- return ""
-}
diff --git a/internal/util/regexes.go b/internal/util/regexes.go
@@ -18,19 +18,78 @@
package util
-import "regexp"
+import (
+ "fmt"
+ "regexp"
+)
+
+const (
+ minimumPasswordEntropy = 60 // dictates password strength. See https://github.com/wagslane/go-password-validator
+ minimumReasonLength = 40
+ maximumReasonLength = 500
+ maximumEmailLength = 256
+ maximumUsernameLength = 64
+ maximumPasswordLength = 64
+ maximumEmojiShortcodeLength = 30
+ maximumHashtagLength = 30
+)
var (
// mention regex can be played around with here: https://regex101.com/r/qwM9D3/1
- mentionRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)`
- mentionRegex = regexp.MustCompile(mentionRegexString)
+ mentionFinderRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)`
+ mentionFinderRegex = regexp.MustCompile(mentionFinderRegexString)
+
// hashtag regex can be played with here: https://regex101.com/r/Vhy8pg/1
- hashtagRegexString = `(?: |^|\W)?#([a-zA-Z0-9]{1,30})(?:\b|\r)`
- hashtagRegex = regexp.MustCompile(hashtagRegexString)
- // emoji regex can be played with here: https://regex101.com/r/478XGM/1
- emojiRegexString = `(?: |^|\W)?:([a-zA-Z0-9_]{2,30}):(?:\b|\r)?`
- emojiRegex = regexp.MustCompile(emojiRegexString)
+ hashtagFinderRegexString = fmt.Sprintf(`(?: |^|\W)?#([a-zA-Z0-9]{1,%d})(?:\b|\r)`, maximumHashtagLength)
+ hashtagFinderRegex = regexp.MustCompile(hashtagFinderRegexString)
+
// emoji shortcode regex can be played with here: https://regex101.com/r/zMDRaG/1
- emojiShortcodeString = `^[a-z0-9_]{2,30}$`
- emojiShortcodeRegex = regexp.MustCompile(emojiShortcodeString)
+ emojiShortcodeRegexString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumEmojiShortcodeLength)
+ emojiShortcodeValidationRegex = regexp.MustCompile(fmt.Sprintf("^%s$", emojiShortcodeRegexString))
+
+ // emoji regex can be played with here: https://regex101.com/r/478XGM/1
+ emojiFinderRegexString = fmt.Sprintf(`(?: |^|\W)?:(%s):(?:\b|\r)?`, emojiShortcodeRegexString)
+ emojiFinderRegex = regexp.MustCompile(emojiFinderRegexString)
+
+ // usernameRegexString defines an acceptable username on this instance
+ usernameRegexString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumUsernameLength)
+ // usernameValidationRegex can be used to validate usernames of new signups
+ usernameValidationRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, usernameRegexString))
+
+ userPathRegexString = fmt.Sprintf(`^?/%s/(%s)$`, UsersPath, usernameRegexString)
+ // userPathRegex parses a path that validates and captures the username part from eg /users/example_username
+ userPathRegex = regexp.MustCompile(userPathRegexString)
+
+ inboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, InboxPath)
+ // inboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/inbox
+ inboxPathRegex = regexp.MustCompile(inboxPathRegexString)
+
+ outboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, OutboxPath)
+ // outboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/outbox
+ outboxPathRegex = regexp.MustCompile(outboxPathRegexString)
+
+ actorPathRegexString = fmt.Sprintf(`^?/%s/(%s)$`, ActorsPath, usernameRegexString)
+ // actorPathRegex parses a path that validates and captures the username part from eg /actors/example_username
+ actorPathRegex = regexp.MustCompile(actorPathRegexString)
+
+ followersPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, FollowersPath)
+ // followersPathRegex parses a path that validates and captures the username part from eg /users/example_username/followers
+ followersPathRegex = regexp.MustCompile(followersPathRegexString)
+
+ followingPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, FollowingPath)
+ // followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/following
+ followingPathRegex = regexp.MustCompile(followingPathRegexString)
+
+ likedPathRegexString = fmt.Sprintf(`^/?%s/%s/%s$`, UsersPath, usernameRegexString, LikedPath)
+ // followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked
+ likedPathRegex = regexp.MustCompile(likedPathRegexString)
+
+ // see https://ihateregex.io/expr/uuid/
+ uuidRegexString = `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}`
+
+ statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, uuidRegexString)
+ // statusesPathRegex parses a path that validates and captures the username part and the uuid part
+ // from eg /users/example_username/statuses/123e4567-e89b-12d3-a456-426655440000.
+ // The regex can be played with here: https://regex101.com/r/G9zuxQ/1
+ statusesPathRegex = regexp.MustCompile(statusesPathRegexString)
)
diff --git a/internal/util/status.go b/internal/util/status.go
@@ -1,96 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package util
-
-import (
- "strings"
-)
-
-// DeriveMentions takes a plaintext (ie., not html-formatted) status,
-// and applies a regex to it to return a deduplicated list of accounts
-// mentioned in that status.
-//
-// It will look for fully-qualified account names in the form "@user@example.org".
-// or the form "@username" for local users.
-// The case of the returned mentions will be lowered, for consistency.
-func DeriveMentions(status string) []string {
- mentionedAccounts := []string{}
- for _, m := range mentionRegex.FindAllStringSubmatch(status, -1) {
- mentionedAccounts = append(mentionedAccounts, m[1])
- }
- return Lower(Unique(mentionedAccounts))
-}
-
-// DeriveHashtags takes a plaintext (ie., not html-formatted) status,
-// and applies a regex to it to return a deduplicated list of hashtags
-// used in that status, without the leading #. The case of the returned
-// tags will be lowered, for consistency.
-func DeriveHashtags(status string) []string {
- tags := []string{}
- for _, m := range hashtagRegex.FindAllStringSubmatch(status, -1) {
- tags = append(tags, m[1])
- }
- return Lower(Unique(tags))
-}
-
-// DeriveEmojis takes a plaintext (ie., not html-formatted) status,
-// and applies a regex to it to return a deduplicated list of emojis
-// used in that status, without the surround ::. The case of the returned
-// emojis will be lowered, for consistency.
-func DeriveEmojis(status string) []string {
- emojis := []string{}
- for _, m := range emojiRegex.FindAllStringSubmatch(status, -1) {
- emojis = append(emojis, m[1])
- }
- return Lower(Unique(emojis))
-}
-
-// Unique returns a deduplicated version of a given string slice.
-func Unique(s []string) []string {
- keys := make(map[string]bool)
- list := []string{}
- for _, entry := range s {
- if _, value := keys[entry]; !value {
- keys[entry] = true
- list = append(list, entry)
- }
- }
- return list
-}
-
-// Lower lowercases all strings in a given string slice
-func Lower(s []string) []string {
- new := []string{}
- for _, i := range s {
- new = append(new, strings.ToLower(i))
- }
- return new
-}
-
-// HTMLFormat takes a plaintext formatted status string, and converts it into
-// a nice HTML-formatted string.
-//
-// This includes:
-// - Replacing line-breaks with <p>
-// - Replacing URLs with hrefs.
-// - Replacing mentions with links to that account's URL as stored in the database.
-func HTMLFormat(status string) string {
- // TODO: write proper HTML formatting logic for a status
- return status
-}
diff --git a/internal/util/status_test.go b/internal/util/status_test.go
@@ -1,105 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package util
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/suite"
-)
-
-type StatusTestSuite struct {
- suite.Suite
-}
-
-func (suite *StatusTestSuite) TestDeriveMentionsOK() {
- statusText := `@dumpsterqueer@example.org testing testing
-
- is this thing on?
-
- @someone_else@testing.best-horse.com can you confirm? @hello@test.lgbt
-
- @thisisalocaluser ! @NORWILL@THIS.one!!
-
- here is a duplicate mention: @hello@test.lgbt
- `
-
- menchies := DeriveMentions(statusText)
- assert.Len(suite.T(), menchies, 4)
- assert.Equal(suite.T(), "@dumpsterqueer@example.org", menchies[0])
- assert.Equal(suite.T(), "@someone_else@testing.best-horse.com", menchies[1])
- assert.Equal(suite.T(), "@hello@test.lgbt", menchies[2])
- assert.Equal(suite.T(), "@thisisalocaluser", menchies[3])
-}
-
-func (suite *StatusTestSuite) TestDeriveMentionsEmpty() {
- statusText := ``
- menchies := DeriveMentions(statusText)
- assert.Len(suite.T(), menchies, 0)
-}
-
-func (suite *StatusTestSuite) TestDeriveHashtagsOK() {
- statusText := `#testing123 #also testing
-
-# testing this one shouldn't work
-
- #thisshouldwork
-
-#ThisShouldAlsoWork #not_this_though
-
-#111111 thisalsoshouldn'twork#### ##`
-
- tags := DeriveHashtags(statusText)
- assert.Len(suite.T(), tags, 5)
- assert.Equal(suite.T(), "testing123", tags[0])
- assert.Equal(suite.T(), "also", tags[1])
- assert.Equal(suite.T(), "thisshouldwork", tags[2])
- assert.Equal(suite.T(), "thisshouldalsowork", tags[3])
- assert.Equal(suite.T(), "111111", tags[4])
-}
-
-func (suite *StatusTestSuite) TestDeriveEmojiOK() {
- statusText := `:test: :another:
-
-Here's some normal text with an :emoji: at the end
-
-:spaces shouldnt work:
-
-:emoji1::emoji2:
-
-:anotheremoji:emoji2:
-:anotheremoji::anotheremoji::anotheremoji::anotheremoji:
-:underscores_ok_too:
-`
-
- tags := DeriveEmojis(statusText)
- assert.Len(suite.T(), tags, 7)
- assert.Equal(suite.T(), "test", tags[0])
- assert.Equal(suite.T(), "another", tags[1])
- assert.Equal(suite.T(), "emoji", tags[2])
- assert.Equal(suite.T(), "emoji1", tags[3])
- assert.Equal(suite.T(), "emoji2", tags[4])
- assert.Equal(suite.T(), "anotheremoji", tags[5])
- assert.Equal(suite.T(), "underscores_ok_too", tags[6])
-}
-
-func TestStatusTestSuite(t *testing.T) {
- suite.Run(t, new(StatusTestSuite))
-}
diff --git a/internal/util/statustools.go b/internal/util/statustools.go
@@ -0,0 +1,96 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package util
+
+import (
+ "strings"
+)
+
+// DeriveMentions takes a plaintext (ie., not html-formatted) status,
+// and applies a regex to it to return a deduplicated list of accounts
+// mentioned in that status.
+//
+// It will look for fully-qualified account names in the form "@user@example.org".
+// or the form "@username" for local users.
+// The case of the returned mentions will be lowered, for consistency.
+func DeriveMentions(status string) []string {
+ mentionedAccounts := []string{}
+ for _, m := range mentionFinderRegex.FindAllStringSubmatch(status, -1) {
+ mentionedAccounts = append(mentionedAccounts, m[1])
+ }
+ return lower(unique(mentionedAccounts))
+}
+
+// DeriveHashtags takes a plaintext (ie., not html-formatted) status,
+// and applies a regex to it to return a deduplicated list of hashtags
+// used in that status, without the leading #. The case of the returned
+// tags will be lowered, for consistency.
+func DeriveHashtags(status string) []string {
+ tags := []string{}
+ for _, m := range hashtagFinderRegex.FindAllStringSubmatch(status, -1) {
+ tags = append(tags, m[1])
+ }
+ return lower(unique(tags))
+}
+
+// DeriveEmojis takes a plaintext (ie., not html-formatted) status,
+// and applies a regex to it to return a deduplicated list of emojis
+// used in that status, without the surround ::. The case of the returned
+// emojis will be lowered, for consistency.
+func DeriveEmojis(status string) []string {
+ emojis := []string{}
+ for _, m := range emojiFinderRegex.FindAllStringSubmatch(status, -1) {
+ emojis = append(emojis, m[1])
+ }
+ return lower(unique(emojis))
+}
+
+// unique returns a deduplicated version of a given string slice.
+func unique(s []string) []string {
+ keys := make(map[string]bool)
+ list := []string{}
+ for _, entry := range s {
+ if _, value := keys[entry]; !value {
+ keys[entry] = true
+ list = append(list, entry)
+ }
+ }
+ return list
+}
+
+// lower lowercases all strings in a given string slice
+func lower(s []string) []string {
+ new := []string{}
+ for _, i := range s {
+ new = append(new, strings.ToLower(i))
+ }
+ return new
+}
+
+// HTMLFormat takes a plaintext formatted status string, and converts it into
+// a nice HTML-formatted string.
+//
+// This includes:
+// - Replacing line-breaks with <p>
+// - Replacing URLs with hrefs.
+// - Replacing mentions with links to that account's URL as stored in the database.
+func HTMLFormat(status string) string {
+ // TODO: write proper HTML formatting logic for a status
+ return status
+}
diff --git a/internal/util/statustools_test.go b/internal/util/statustools_test.go
@@ -0,0 +1,106 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package util_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+type StatusTestSuite struct {
+ suite.Suite
+}
+
+func (suite *StatusTestSuite) TestDeriveMentionsOK() {
+ statusText := `@dumpsterqueer@example.org testing testing
+
+ is this thing on?
+
+ @someone_else@testing.best-horse.com can you confirm? @hello@test.lgbt
+
+ @thisisalocaluser ! @NORWILL@THIS.one!!
+
+ here is a duplicate mention: @hello@test.lgbt
+ `
+
+ menchies := util.DeriveMentions(statusText)
+ assert.Len(suite.T(), menchies, 4)
+ assert.Equal(suite.T(), "@dumpsterqueer@example.org", menchies[0])
+ assert.Equal(suite.T(), "@someone_else@testing.best-horse.com", menchies[1])
+ assert.Equal(suite.T(), "@hello@test.lgbt", menchies[2])
+ assert.Equal(suite.T(), "@thisisalocaluser", menchies[3])
+}
+
+func (suite *StatusTestSuite) TestDeriveMentionsEmpty() {
+ statusText := ``
+ menchies := util.DeriveMentions(statusText)
+ assert.Len(suite.T(), menchies, 0)
+}
+
+func (suite *StatusTestSuite) TestDeriveHashtagsOK() {
+ statusText := `#testing123 #also testing
+
+# testing this one shouldn't work
+
+ #thisshouldwork
+
+#ThisShouldAlsoWork #not_this_though
+
+#111111 thisalsoshouldn'twork#### ##`
+
+ tags := util.DeriveHashtags(statusText)
+ assert.Len(suite.T(), tags, 5)
+ assert.Equal(suite.T(), "testing123", tags[0])
+ assert.Equal(suite.T(), "also", tags[1])
+ assert.Equal(suite.T(), "thisshouldwork", tags[2])
+ assert.Equal(suite.T(), "thisshouldalsowork", tags[3])
+ assert.Equal(suite.T(), "111111", tags[4])
+}
+
+func (suite *StatusTestSuite) TestDeriveEmojiOK() {
+ statusText := `:test: :another:
+
+Here's some normal text with an :emoji: at the end
+
+:spaces shouldnt work:
+
+:emoji1::emoji2:
+
+:anotheremoji:emoji2:
+:anotheremoji::anotheremoji::anotheremoji::anotheremoji:
+:underscores_ok_too:
+`
+
+ tags := util.DeriveEmojis(statusText)
+ assert.Len(suite.T(), tags, 7)
+ assert.Equal(suite.T(), "test", tags[0])
+ assert.Equal(suite.T(), "another", tags[1])
+ assert.Equal(suite.T(), "emoji", tags[2])
+ assert.Equal(suite.T(), "emoji1", tags[3])
+ assert.Equal(suite.T(), "emoji2", tags[4])
+ assert.Equal(suite.T(), "anotheremoji", tags[5])
+ assert.Equal(suite.T(), "underscores_ok_too", tags[6])
+}
+
+func TestStatusTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusTestSuite))
+}
diff --git a/internal/util/uri.go b/internal/util/uri.go
@@ -0,0 +1,218 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package util
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+const (
+ // UsersPath is for serving users info
+ UsersPath = "users"
+ // ActorsPath is for serving actors info
+ ActorsPath = "actors"
+ // StatusesPath is for serving statuses
+ StatusesPath = "statuses"
+ // InboxPath represents the webfinger inbox location
+ InboxPath = "inbox"
+ // OutboxPath represents the webfinger outbox location
+ OutboxPath = "outbox"
+ // FollowersPath represents the webfinger followers location
+ FollowersPath = "followers"
+ // FollowingPath represents the webfinger following location
+ FollowingPath = "following"
+ // LikedPath represents the webfinger liked location
+ LikedPath = "liked"
+ // CollectionsPath represents the webfinger collections location
+ CollectionsPath = "collections"
+ // FeaturedPath represents the webfinger featured location
+ FeaturedPath = "featured"
+ // PublicKeyPath is for serving an account's public key
+ PublicKeyPath = "publickey"
+)
+
+// APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains
+type APContextKey string
+
+const (
+ // APActivity can be used to set and retrieve the actual go-fed pub.Activity within a context.
+ APActivity APContextKey = "activity"
+ // APAccount can be used the set and retrieve the account being interacted with
+ APAccount APContextKey = "account"
+ // APRequestingAccount can be used to set and retrieve the account of an incoming federation request.
+ APRequestingAccount APContextKey = "requestingAccount"
+ // APRequestingPublicKeyID can be used to set and retrieve the public key ID of an incoming federation request.
+ APRequestingPublicKeyID APContextKey = "requestingPublicKeyID"
+)
+
+type ginContextKey struct{}
+
+// GinContextKey is used solely for setting and retrieving the gin context from a context.Context
+var GinContextKey = &ginContextKey{}
+
+// UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc.
+type UserURIs struct {
+ // The web URL of the instance host, eg https://example.org
+ HostURL string
+ // The web URL of the user, eg., https://example.org/@example_user
+ UserURL string
+ // The web URL for statuses of this user, eg., https://example.org/@example_user/statuses
+ StatusesURL string
+
+ // The webfinger URI of this user, eg., https://example.org/users/example_user
+ UserURI string
+ // The webfinger URI for this user's statuses, eg., https://example.org/users/example_user/statuses
+ StatusesURI string
+ // The webfinger URI for this user's activitypub inbox, eg., https://example.org/users/example_user/inbox
+ InboxURI string
+ // The webfinger URI for this user's activitypub outbox, eg., https://example.org/users/example_user/outbox
+ OutboxURI string
+ // The webfinger URI for this user's followers, eg., https://example.org/users/example_user/followers
+ FollowersURI string
+ // The webfinger URI for this user's following, eg., https://example.org/users/example_user/following
+ FollowingURI string
+ // The webfinger URI for this user's liked posts eg., https://example.org/users/example_user/liked
+ LikedURI string
+ // The webfinger URI for this user's featured collections, eg., https://example.org/users/example_user/collections/featured
+ CollectionURI string
+ // The URI for this user's public key, eg., https://example.org/users/example_user/publickey
+ PublicKeyURI string
+}
+
+// GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host.
+func GenerateURIsForAccount(username string, protocol string, host string) *UserURIs {
+ // The below URLs are used for serving web requests
+ hostURL := fmt.Sprintf("%s://%s", protocol, host)
+ userURL := fmt.Sprintf("%s/@%s", hostURL, username)
+ statusesURL := fmt.Sprintf("%s/%s", userURL, StatusesPath)
+
+ // the below URIs are used in ActivityPub and Webfinger
+ userURI := fmt.Sprintf("%s/%s/%s", hostURL, UsersPath, username)
+ statusesURI := fmt.Sprintf("%s/%s", userURI, StatusesPath)
+ inboxURI := fmt.Sprintf("%s/%s", userURI, InboxPath)
+ outboxURI := fmt.Sprintf("%s/%s", userURI, OutboxPath)
+ followersURI := fmt.Sprintf("%s/%s", userURI, FollowersPath)
+ followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath)
+ likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath)
+ collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath)
+ publicKeyURI := fmt.Sprintf("%s/%s", userURI, PublicKeyPath)
+
+ return &UserURIs{
+ HostURL: hostURL,
+ UserURL: userURL,
+ StatusesURL: statusesURL,
+
+ UserURI: userURI,
+ StatusesURI: statusesURI,
+ InboxURI: inboxURI,
+ OutboxURI: outboxURI,
+ FollowersURI: followersURI,
+ FollowingURI: followingURI,
+ LikedURI: likedURI,
+ CollectionURI: collectionURI,
+ PublicKeyURI: publicKeyURI,
+ }
+}
+
+// IsUserPath returns true if the given URL path corresponds to eg /users/example_username
+func IsUserPath(id *url.URL) bool {
+ return userPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsInboxPath returns true if the given URL path corresponds to eg /users/example_username/inbox
+func IsInboxPath(id *url.URL) bool {
+ return inboxPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsOutboxPath returns true if the given URL path corresponds to eg /users/example_username/outbox
+func IsOutboxPath(id *url.URL) bool {
+ return outboxPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsInstanceActorPath returns true if the given URL path corresponds to eg /actors/example_username
+func IsInstanceActorPath(id *url.URL) bool {
+ return actorPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsFollowersPath returns true if the given URL path corresponds to eg /users/example_username/followers
+func IsFollowersPath(id *url.URL) bool {
+ return followersPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsFollowingPath returns true if the given URL path corresponds to eg /users/example_username/following
+func IsFollowingPath(id *url.URL) bool {
+ return followingPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsLikedPath returns true if the given URL path corresponds to eg /users/example_username/liked
+func IsLikedPath(id *url.URL) bool {
+ return likedPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS
+func IsStatusesPath(id *url.URL) bool {
+ return statusesPathRegex.MatchString(strings.ToLower(id.Path))
+}
+
+// ParseStatusesPath returns the username and uuid from a path such as /users/example_username/statuses/SOME_UUID_OF_A_STATUS
+func ParseStatusesPath(id *url.URL) (username string, uuid string, err error) {
+ matches := statusesPathRegex.FindStringSubmatch(id.Path)
+ if len(matches) != 3 {
+ err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches))
+ return
+ }
+ username = matches[1]
+ uuid = matches[2]
+ return
+}
+
+// ParseUserPath returns the username from a path such as /users/example_username
+func ParseUserPath(id *url.URL) (username string, err error) {
+ matches := userPathRegex.FindStringSubmatch(id.Path)
+ if len(matches) != 2 {
+ err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches))
+ return
+ }
+ username = matches[1]
+ return
+}
+
+// ParseInboxPath returns the username from a path such as /users/example_username/inbox
+func ParseInboxPath(id *url.URL) (username string, err error) {
+ matches := inboxPathRegex.FindStringSubmatch(id.Path)
+ if len(matches) != 2 {
+ err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches))
+ return
+ }
+ username = matches[1]
+ return
+}
+
+// ParseOutboxPath returns the username from a path such as /users/example_username/outbox
+func ParseOutboxPath(id *url.URL) (username string, err error) {
+ matches := outboxPathRegex.FindStringSubmatch(id.Path)
+ if len(matches) != 2 {
+ err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches))
+ return
+ }
+ username = matches[1]
+ return
+}
diff --git a/internal/util/validation.go b/internal/util/validation.go
@@ -22,45 +22,22 @@ import (
"errors"
"fmt"
"net/mail"
- "regexp"
pwv "github.com/wagslane/go-password-validator"
"golang.org/x/text/language"
)
-const (
- // MinimumPasswordEntropy dictates password strength. See https://github.com/wagslane/go-password-validator
- MinimumPasswordEntropy = 60
- // MinimumReasonLength is the length of chars we expect as a bare minimum effort
- MinimumReasonLength = 40
- // MaximumReasonLength is the maximum amount of chars we're happy to accept
- MaximumReasonLength = 500
- // MaximumEmailLength is the maximum length of an email address we're happy to accept
- MaximumEmailLength = 256
- // MaximumUsernameLength is the maximum length of a username we're happy to accept
- MaximumUsernameLength = 64
- // MaximumPasswordLength is the maximum length of a password we're happy to accept
- MaximumPasswordLength = 64
- // NewUsernameRegexString is string representation of the regular expression for validating usernames
- NewUsernameRegexString = `^[a-z0-9_]+$`
-)
-
-var (
- // NewUsernameRegex is the compiled regex for validating new usernames
- NewUsernameRegex = regexp.MustCompile(NewUsernameRegexString)
-)
-
// ValidateNewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok.
func ValidateNewPassword(password string) error {
if password == "" {
return errors.New("no password provided")
}
- if len(password) > MaximumPasswordLength {
- return fmt.Errorf("password should be no more than %d chars", MaximumPasswordLength)
+ if len(password) > maximumPasswordLength {
+ return fmt.Errorf("password should be no more than %d chars", maximumPasswordLength)
}
- return pwv.Validate(password, MinimumPasswordEntropy)
+ return pwv.Validate(password, minimumPasswordEntropy)
}
// ValidateUsername makes sure that a given username is valid (ie., letters, numbers, underscores, check length).
@@ -70,11 +47,11 @@ func ValidateUsername(username string) error {
return errors.New("no username provided")
}
- if len(username) > MaximumUsernameLength {
- return fmt.Errorf("username should be no more than %d chars but '%s' was %d", MaximumUsernameLength, username, len(username))
+ if len(username) > maximumUsernameLength {
+ return fmt.Errorf("username should be no more than %d chars but '%s' was %d", maximumUsernameLength, username, len(username))
}
- if !NewUsernameRegex.MatchString(username) {
+ if !usernameValidationRegex.MatchString(username) {
return fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", username)
}
@@ -88,8 +65,8 @@ func ValidateEmail(email string) error {
return errors.New("no email provided")
}
- if len(email) > MaximumEmailLength {
- return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", MaximumEmailLength, email, len(email))
+ if len(email) > maximumEmailLength {
+ return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", maximumEmailLength, email, len(email))
}
_, err := mail.ParseAddress(email)
@@ -118,12 +95,12 @@ func ValidateSignUpReason(reason string, reasonRequired bool) error {
return errors.New("no reason provided")
}
- if len(reason) < MinimumReasonLength {
- return fmt.Errorf("reason should be at least %d chars but '%s' was %d", MinimumReasonLength, reason, len(reason))
+ if len(reason) < minimumReasonLength {
+ return fmt.Errorf("reason should be at least %d chars but '%s' was %d", minimumReasonLength, reason, len(reason))
}
- if len(reason) > MaximumReasonLength {
- return fmt.Errorf("reason should be no more than %d chars but given reason was %d", MaximumReasonLength, len(reason))
+ if len(reason) > maximumReasonLength {
+ return fmt.Errorf("reason should be no more than %d chars but given reason was %d", maximumReasonLength, len(reason))
}
return nil
}
@@ -150,7 +127,7 @@ func ValidatePrivacy(privacy string) error {
// for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters,
// lowercase a-z, numbers, and underscores.
func ValidateEmojiShortcode(shortcode string) error {
- if !emojiShortcodeRegex.MatchString(shortcode) {
+ if !emojiShortcodeValidationRegex.MatchString(shortcode) {
return fmt.Errorf("shortcode %s did not pass validation, must be between 2 and 30 characters, lowercase letters, numbers, and underscores only", shortcode)
}
return nil
diff --git a/internal/util/validation_test.go b/internal/util/validation_test.go
@@ -16,7 +16,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-package util
+package util_test
import (
"errors"
@@ -25,6 +25,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
type ValidationTestSuite struct {
@@ -42,42 +43,42 @@ func (suite *ValidationTestSuite) TestCheckPasswordStrength() {
strongPassword := "3dX5@Zc%mV*W2MBNEy$@"
var err error
- err = ValidateNewPassword(empty)
+ err = util.ValidateNewPassword(empty)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("no password provided"), err)
}
- err = ValidateNewPassword(terriblePassword)
+ err = util.ValidateNewPassword(terriblePassword)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"), err)
}
- err = ValidateNewPassword(weakPassword)
+ err = util.ValidateNewPassword(weakPassword)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using numbers or using a longer password"), err)
}
- err = ValidateNewPassword(shortPassword)
+ err = util.ValidateNewPassword(shortPassword)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err)
}
- err = ValidateNewPassword(specialPassword)
+ err = util.ValidateNewPassword(specialPassword)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err)
}
- err = ValidateNewPassword(longPassword)
+ err = util.ValidateNewPassword(longPassword)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateNewPassword(tooLong)
+ err = util.ValidateNewPassword(tooLong)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("password should be no more than 64 chars"), err)
}
- err = ValidateNewPassword(strongPassword)
+ err = util.ValidateNewPassword(strongPassword)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
@@ -94,42 +95,42 @@ func (suite *ValidationTestSuite) TestValidateUsername() {
goodUsername := "this_is_a_good_username"
var err error
- err = ValidateUsername(empty)
+ err = util.ValidateUsername(empty)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("no username provided"), err)
}
- err = ValidateUsername(tooLong)
+ err = util.ValidateUsername(tooLong)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("username should be no more than 64 chars but '%s' was 66", tooLong), err)
}
- err = ValidateUsername(withSpaces)
+ err = util.ValidateUsername(withSpaces)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", withSpaces), err)
}
- err = ValidateUsername(weirdChars)
+ err = util.ValidateUsername(weirdChars)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", weirdChars), err)
}
- err = ValidateUsername(leadingSpace)
+ err = util.ValidateUsername(leadingSpace)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", leadingSpace), err)
}
- err = ValidateUsername(trailingSpace)
+ err = util.ValidateUsername(trailingSpace)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", trailingSpace), err)
}
- err = ValidateUsername(newlines)
+ err = util.ValidateUsername(newlines)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", newlines), err)
}
- err = ValidateUsername(goodUsername)
+ err = util.ValidateUsername(goodUsername)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
@@ -144,32 +145,32 @@ func (suite *ValidationTestSuite) TestValidateEmail() {
emailAddress := "thisis.actually@anemail.address"
var err error
- err = ValidateEmail(empty)
+ err = util.ValidateEmail(empty)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("no email provided"), err)
}
- err = ValidateEmail(notAnEmailAddress)
+ err = util.ValidateEmail(notAnEmailAddress)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("mail: missing '@' or angle-addr"), err)
}
- err = ValidateEmail(almostAnEmailAddress)
+ err = util.ValidateEmail(almostAnEmailAddress)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("mail: no angle-addr"), err)
}
- err = ValidateEmail(aWebsite)
+ err = util.ValidateEmail(aWebsite)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("mail: missing '@' or angle-addr"), err)
}
- err = ValidateEmail(tooLong)
+ err = util.ValidateEmail(tooLong)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), fmt.Errorf("email address should be no more than 256 chars but '%s' was 286", tooLong), err)
}
- err = ValidateEmail(emailAddress)
+ err = util.ValidateEmail(emailAddress)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
@@ -187,47 +188,47 @@ func (suite *ValidationTestSuite) TestValidateLanguage() {
german := "de"
var err error
- err = ValidateLanguage(empty)
+ err = util.ValidateLanguage(empty)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("no language provided"), err)
}
- err = ValidateLanguage(notALanguage)
+ err = util.ValidateLanguage(notALanguage)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("language: tag is not well-formed"), err)
}
- err = ValidateLanguage(english)
+ err = util.ValidateLanguage(english)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateLanguage(capitalEnglish)
+ err = util.ValidateLanguage(capitalEnglish)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateLanguage(arabic3Letters)
+ err = util.ValidateLanguage(arabic3Letters)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateLanguage(mixedCapsEnglish)
+ err = util.ValidateLanguage(mixedCapsEnglish)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateLanguage(englishUS)
+ err = util.ValidateLanguage(englishUS)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("language: tag is not well-formed"), err)
}
- err = ValidateLanguage(dutch)
+ err = util.ValidateLanguage(dutch)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateLanguage(german)
+ err = util.ValidateLanguage(german)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
@@ -241,43 +242,43 @@ func (suite *ValidationTestSuite) TestValidateReason() {
var err error
// check with no reason required
- err = ValidateSignUpReason(empty, false)
+ err = util.ValidateSignUpReason(empty, false)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateSignUpReason(badReason, false)
+ err = util.ValidateSignUpReason(badReason, false)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateSignUpReason(tooLong, false)
+ err = util.ValidateSignUpReason(tooLong, false)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
- err = ValidateSignUpReason(goodReason, false)
+ err = util.ValidateSignUpReason(goodReason, false)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
// check with reason required
- err = ValidateSignUpReason(empty, true)
+ err = util.ValidateSignUpReason(empty, true)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("no reason provided"), err)
}
- err = ValidateSignUpReason(badReason, true)
+ err = util.ValidateSignUpReason(badReason, true)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("reason should be at least 40 chars but 'because' was 7"), err)
}
- err = ValidateSignUpReason(tooLong, true)
+ err = util.ValidateSignUpReason(tooLong, true)
if assert.Error(suite.T(), err) {
assert.Equal(suite.T(), errors.New("reason should be no more than 500 chars but given reason was 600"), err)
}
- err = ValidateSignUpReason(goodReason, true)
+ err = util.ValidateSignUpReason(goodReason, true)
if assert.NoError(suite.T(), err) {
assert.Equal(suite.T(), nil, err)
}
diff --git a/testrig/actions.go b/testrig/actions.go
@@ -19,24 +19,26 @@
package testrig
import (
+ "bytes"
"context"
"fmt"
+ "io/ioutil"
+ "net/http"
"os"
"os/signal"
"syscall"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/action"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/admin"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/app"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/auth"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
- mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/security"
- "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
- "github.com/superseriousbusiness/gotosocial/internal/cache"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/account"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/app"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/auth"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver"
+ mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
+ "github.com/superseriousbusiness/gotosocial/internal/api/security"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gotosocial"
@@ -44,33 +46,39 @@ import (
// Run creates and starts a gotosocial testrig server
var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logrus.Logger) error {
+ c := NewTestConfig()
dbService := NewTestDB()
router := NewTestRouter()
storageBackend := NewTestStorage()
- mediaHandler := NewTestMediaHandler(dbService, storageBackend)
- oauthServer := NewTestOauthServer(dbService)
- distributor := NewTestDistributor()
- if err := distributor.Start(); err != nil {
- return fmt.Errorf("error starting distributor: %s", err)
- }
- mastoConverter := NewTestMastoConverter(dbService)
- c := NewTestConfig()
+ typeConverter := NewTestTypeConverter(dbService)
+ transportController := NewTestTransportController(NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
+ r := ioutil.NopCloser(bytes.NewReader([]byte{}))
+ return &http.Response{
+ StatusCode: 200,
+ Body: r,
+ }, nil
+ }))
+ federator := federation.NewFederator(dbService, transportController, c, log, typeConverter)
+ processor := NewTestProcessor(dbService, storageBackend, federator)
+ if err := processor.Start(); err != nil {
+ return fmt.Errorf("error starting processor: %s", err)
+ }
StandardDBSetup(dbService)
StandardStorageSetup(storageBackend, "./testrig/media")
// build client api modules
- authModule := auth.New(oauthServer, dbService, log)
- accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log)
- appsModule := app.New(oauthServer, dbService, mastoConverter, log)
- mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log)
- fileServerModule := fileserver.New(c, dbService, storageBackend, log)
- adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log)
- statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log)
+ authModule := auth.New(c, dbService, NewTestOauthServer(dbService), log)
+ accountModule := account.New(c, processor, log)
+ appsModule := app.New(c, processor, log)
+ mm := mediaModule.New(c, processor, log)
+ fileServerModule := fileserver.New(c, processor, log)
+ adminModule := admin.New(c, processor, log)
+ statusModule := status.New(c, processor, log)
securityModule := security.New(c, log)
- apiModules := []apimodule.ClientAPIModule{
+ apis := []api.ClientModule{
// modules with middleware go first
securityModule,
authModule,
@@ -84,20 +92,13 @@ var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logr
statusModule,
}
- for _, m := range apiModules {
+ for _, m := range apis {
if err := m.Route(router); err != nil {
return fmt.Errorf("routing error: %s", err)
}
- if err := m.CreateTables(dbService); err != nil {
- return fmt.Errorf("table creation error: %s", err)
- }
}
- // if err := dbService.CreateInstanceAccount(); err != nil {
- // return fmt.Errorf("error creating instance account: %s", err)
- // }
-
- gts, err := gotosocial.New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c)
+ gts, err := gotosocial.New(dbService, router, federator, c)
if err != nil {
return fmt.Errorf("error creating gotosocial service: %s", err)
}
diff --git a/testrig/db.go b/testrig/db.go
@@ -23,7 +23,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
@@ -54,7 +54,7 @@ func NewTestDB() db.DB {
config := NewTestConfig()
l := logrus.New()
l.SetLevel(logrus.TraceLevel)
- testDB, err := db.New(context.Background(), config, l)
+ testDB, err := db.NewPostgresService(context.Background(), config, l)
if err != nil {
panic(err)
}
diff --git a/testrig/distributor.go b/testrig/distributor.go
@@ -1,26 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package testrig
-
-import "github.com/superseriousbusiness/gotosocial/internal/distributor"
-
-// NewTestDistributor returns a Distributor suitable for testing purposes
-func NewTestDistributor() distributor.Distributor {
- return distributor.New(NewTestLog())
-}
diff --git a/testrig/federator.go b/testrig/federator.go
@@ -0,0 +1,29 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package testrig
+
+import (
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/transport"
+)
+
+func NewTestFederator(db db.DB, tc transport.Controller) federation.Federator {
+ return federation.NewFederator(db, tc, NewTestConfig(), NewTestLog(), NewTestTypeConverter(db))
+}
diff --git a/testrig/mastoconverter.go b/testrig/mastoconverter.go
@@ -1,29 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
-
-package testrig
-
-import (
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
-)
-
-// NewTestMastoConverter returned a mastotypes converter with the given db and the default test config
-func NewTestMastoConverter(db db.DB) mastotypes.Converter {
- return mastotypes.New(NewTestConfig(), db)
-}
diff --git a/testrig/media/test-jpeg.jpg b/testrig/media/test-jpeg.jpg
Binary files differ.
diff --git a/testrig/processor.go b/testrig/processor.go
@@ -0,0 +1,31 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package testrig
+
+import (
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/message"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+)
+
+// NewTestProcessor returns a Processor suitable for testing purposes
+func NewTestProcessor(db db.DB, storage storage.Storage, federator federation.Federator) message.Processor {
+ return message.NewProcessor(NewTestConfig(), NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, db, NewTestLog())
+}
diff --git a/testrig/testmodels.go b/testrig/testmodels.go
@@ -19,13 +19,26 @@
package testrig
import (
+ "bytes"
+ "context"
+ "crypto"
"crypto/rand"
"crypto/rsa"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "io/ioutil"
"net"
+ "net/http"
+ "net/url"
"time"
- "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/go-fed/activity/pub"
+ "github.com/go-fed/activity/streams"
+ "github.com/go-fed/activity/streams/vocab"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
// NewTestTokens returns a map of tokens keyed according to which account the token belongs to.
@@ -274,15 +287,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
URI: "http://localhost:8080/users/weed_lord420",
URL: "http://localhost:8080/@weed_lord420",
LastWebfingeredAt: time.Time{},
- InboxURL: "http://localhost:8080/users/weed_lord420/inbox",
- OutboxURL: "http://localhost:8080/users/weed_lord420/outbox",
- SharedInboxURL: "",
- FollowersURL: "http://localhost:8080/users/weed_lord420/followers",
- FeaturedCollectionURL: "http://localhost:8080/users/weed_lord420/collections/featured",
+ InboxURI: "http://localhost:8080/users/weed_lord420/inbox",
+ OutboxURI: "http://localhost:8080/users/weed_lord420/outbox",
+ FollowersURI: "http://localhost:8080/users/weed_lord420/followers",
+ FollowingURI: "http://localhost:8080/users/weed_lord420/following",
+ FeaturedCollectionURI: "http://localhost:8080/users/weed_lord420/collections/featured",
ActorType: gtsmodel.ActivityStreamsPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{},
+ PublicKeyURI: "http://localhost:8080/users/weed_lord420#main-key",
SensitizedAt: time.Time{},
SilencedAt: time.Time{},
SuspendedAt: time.Time{},
@@ -310,12 +324,13 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Language: "en",
URI: "http://localhost:8080/users/admin",
URL: "http://localhost:8080/@admin",
+ PublicKeyURI: "http://localhost:8080/users/admin#main-key",
LastWebfingeredAt: time.Time{},
- InboxURL: "http://localhost:8080/users/admin/inbox",
- OutboxURL: "http://localhost:8080/users/admin/outbox",
- SharedInboxURL: "",
- FollowersURL: "http://localhost:8080/users/admin/followers",
- FeaturedCollectionURL: "http://localhost:8080/users/admin/collections/featured",
+ InboxURI: "http://localhost:8080/users/admin/inbox",
+ OutboxURI: "http://localhost:8080/users/admin/outbox",
+ FollowersURI: "http://localhost:8080/users/admin/followers",
+ FollowingURI: "http://localhost:8080/users/admin/following",
+ FeaturedCollectionURI: "http://localhost:8080/users/admin/collections/featured",
ActorType: gtsmodel.ActivityStreamsPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{},
@@ -348,15 +363,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
URI: "http://localhost:8080/users/the_mighty_zork",
URL: "http://localhost:8080/@the_mighty_zork",
LastWebfingeredAt: time.Time{},
- InboxURL: "http://localhost:8080/users/the_mighty_zork/inbox",
- OutboxURL: "http://localhost:8080/users/the_mighty_zork/outbox",
- SharedInboxURL: "",
- FollowersURL: "http://localhost:8080/users/the_mighty_zork/followers",
- FeaturedCollectionURL: "http://localhost:8080/users/the_mighty_zork/collections/featured",
+ InboxURI: "http://localhost:8080/users/the_mighty_zork/inbox",
+ OutboxURI: "http://localhost:8080/users/the_mighty_zork/outbox",
+ FollowersURI: "http://localhost:8080/users/the_mighty_zork/followers",
+ FollowingURI: "http://localhost:8080/users/the_mighty_zork/following",
+ FeaturedCollectionURI: "http://localhost:8080/users/the_mighty_zork/collections/featured",
ActorType: gtsmodel.ActivityStreamsPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{},
+ PublicKeyURI: "http://localhost:8080/users/the_mighty_zork#main-key",
SensitizedAt: time.Time{},
SilencedAt: time.Time{},
SuspendedAt: time.Time{},
@@ -385,15 +401,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
URI: "http://localhost:8080/users/1happyturtle",
URL: "http://localhost:8080/@1happyturtle",
LastWebfingeredAt: time.Time{},
- InboxURL: "http://localhost:8080/users/1happyturtle/inbox",
- OutboxURL: "http://localhost:8080/users/1happyturtle/outbox",
- SharedInboxURL: "",
- FollowersURL: "http://localhost:8080/users/1happyturtle/followers",
- FeaturedCollectionURL: "http://localhost:8080/users/1happyturtle/collections/featured",
+ InboxURI: "http://localhost:8080/users/1happyturtle/inbox",
+ OutboxURI: "http://localhost:8080/users/1happyturtle/outbox",
+ FollowersURI: "http://localhost:8080/users/1happyturtle/followers",
+ FollowingURI: "http://localhost:8080/users/1happyturtle/following",
+ FeaturedCollectionURI: "http://localhost:8080/users/1happyturtle/collections/featured",
ActorType: gtsmodel.ActivityStreamsPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{},
+ PublicKeyURI: "http://localhost:8080/users/1happyturtle#main-key",
SensitizedAt: time.Time{},
SilencedAt: time.Time{},
SuspendedAt: time.Time{},
@@ -426,18 +443,19 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Discoverable: true,
Sensitive: false,
Language: "en",
- URI: "https://fossbros-anonymous.io/users/foss_satan",
- URL: "https://fossbros-anonymous.io/@foss_satan",
+ URI: "http://fossbros-anonymous.io/users/foss_satan",
+ URL: "http://fossbros-anonymous.io/@foss_satan",
LastWebfingeredAt: time.Time{},
- InboxURL: "https://fossbros-anonymous.io/users/foss_satan/inbox",
- OutboxURL: "https://fossbros-anonymous.io/users/foss_satan/outbox",
- SharedInboxURL: "",
- FollowersURL: "https://fossbros-anonymous.io/users/foss_satan/followers",
- FeaturedCollectionURL: "https://fossbros-anonymous.io/users/foss_satan/collections/featured",
+ InboxURI: "http://fossbros-anonymous.io/users/foss_satan/inbox",
+ OutboxURI: "http://fossbros-anonymous.io/users/foss_satan/outbox",
+ FollowersURI: "http://fossbros-anonymous.io/users/foss_satan/followers",
+ FollowingURI: "http://fossbros-anonymous.io/users/foss_satan/following",
+ FeaturedCollectionURI: "http://fossbros-anonymous.io/users/foss_satan/collections/featured",
ActorType: gtsmodel.ActivityStreamsPerson,
AlsoKnownAs: "",
- PrivateKey: &rsa.PrivateKey{},
- PublicKey: nil,
+ PrivateKey: nil,
+ PublicKey: &rsa.PublicKey{},
+ PublicKeyURI: "http://fossbros-anonymous.io/users/foss_satan#main-key",
SensitizedAt: time.Time{},
SilencedAt: time.Time{},
SuspendedAt: time.Time{},
@@ -468,10 +486,10 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
}
pub := &priv.PublicKey
- // only local accounts get a private key
- if v.Domain == "" {
- v.PrivateKey = priv
- }
+ // normally only local accounts get a private key (obviously)
+ // but for testing purposes and signing requests, we'll give
+ // remote accounts a private key as well
+ v.PrivateKey = priv
v.PublicKey = pub
}
return accounts
@@ -676,25 +694,26 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
func NewTestEmojis() map[string]*gtsmodel.Emoji {
return map[string]*gtsmodel.Emoji{
"rainbow": {
- ID: "a96ec4f3-1cae-47e4-a508-f9d66a6b221b",
- Shortcode: "rainbow",
- Domain: "",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- ImageRemoteURL: "",
- ImageStaticRemoteURL: "",
- ImageURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
- ImagePath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
- ImageStaticURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
- ImageStaticPath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
- ImageContentType: "image/png",
- ImageFileSize: 36702,
- ImageStaticFileSize: 10413,
- ImageUpdatedAt: time.Now(),
- Disabled: false,
- URI: "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b",
- VisibleInPicker: true,
- CategoryID: "",
+ ID: "a96ec4f3-1cae-47e4-a508-f9d66a6b221b",
+ Shortcode: "rainbow",
+ Domain: "",
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ ImageRemoteURL: "",
+ ImageStaticRemoteURL: "",
+ ImageURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
+ ImagePath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
+ ImageStaticURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
+ ImageStaticPath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png",
+ ImageContentType: "image/png",
+ ImageStaticContentType: "image/png",
+ ImageFileSize: 36702,
+ ImageStaticFileSize: 10413,
+ ImageUpdatedAt: time.Now(),
+ Disabled: false,
+ URI: "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b",
+ VisibleInPicker: true,
+ CategoryID: "",
},
}
}
@@ -993,3 +1012,436 @@ func NewTestFaves() map[string]*gtsmodel.StatusFave {
},
}
}
+
+type ActivityWithSignature struct {
+ Activity pub.Activity
+ SignatureHeader string
+ DigestHeader string
+ DateHeader string
+}
+
+// NewTestActivities returns a bunch of pub.Activity types for use in testing the federation protocols.
+// A struct of accounts needs to be passed in because the activities will also be bundled along with
+// their requesting signatures.
+func NewTestActivities(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature {
+ dmForZork := newNote(
+ URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6"),
+ URLMustParse("https://fossbros-anonymous.io/@foss_satan/5424b153-4553-4f30-9358-7b92f7cd42f6"),
+ "hey zork here's a new private note for you",
+ "new note for zork",
+ URLMustParse("https://fossbros-anonymous.io/users/foss_satan"),
+ []*url.URL{URLMustParse("http://localhost:8080/users/the_mighty_zork")},
+ nil,
+ true)
+ createDmForZork := wrapNoteInCreate(
+ URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6/activity"),
+ URLMustParse("https://fossbros-anonymous.io/users/foss_satan"),
+ time.Now(),
+ dmForZork)
+ sig, digest, date := getSignatureForActivity(createDmForZork, accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].InboxURI))
+
+ return map[string]ActivityWithSignature{
+ "dm_for_zork": {
+ Activity: createDmForZork,
+ SignatureHeader: sig,
+ DigestHeader: digest,
+ DateHeader: date,
+ },
+ }
+}
+
+// NewTestFediPeople returns a bunch of activity pub Person representations for testing converters and so on.
+func NewTestFediPeople() map[string]typeutils.Accountable {
+ new_person_1priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ panic(err)
+ }
+ new_person_1pub := &new_person_1priv.PublicKey
+
+ return map[string]typeutils.Accountable{
+ "new_person_1": newPerson(
+ URLMustParse("https://unknown-instance.com/users/brand_new_person"),
+ URLMustParse("https://unknown-instance.com/users/brand_new_person/following"),
+ URLMustParse("https://unknown-instance.com/users/brand_new_person/followers"),
+ URLMustParse("https://unknown-instance.com/users/brand_new_person/inbox"),
+ URLMustParse("https://unknown-instance.com/users/brand_new_person/outbox"),
+ URLMustParse("https://unknown-instance.com/users/brand_new_person/collections/featured"),
+ "brand_new_person",
+ "Geoff Brando New Personson",
+ "hey I'm a new person, your instance hasn't seen me yet uwu",
+ URLMustParse("https://unknown-instance.com/@brand_new_person"),
+ true,
+ URLMustParse("https://unknown-instance.com/users/brand_new_person#main-key"),
+ new_person_1pub,
+ URLMustParse("https://unknown-instance.com/media/some_avatar_filename.jpeg"),
+ "image/jpeg",
+ URLMustParse("https://unknown-instance.com/media/some_header_filename.jpeg"),
+ "image/png",
+ ),
+ }
+}
+
+func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature {
+ sig, digest, date := getSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].URI))
+ return map[string]ActivityWithSignature{
+ "foss_satan_dereference_zork": {
+ SignatureHeader: sig,
+ DigestHeader: digest,
+ DateHeader: date,
+ },
+ }
+}
+
+// getSignatureForActivity does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive
+// the HTTP Signature for the given activity, public key ID, private key, and destination.
+func getSignatureForActivity(activity pub.Activity, pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) {
+ // create a client that basically just pulls the signature out of the request and sets it
+ client := &mockHTTPClient{
+ do: func(req *http.Request) (*http.Response, error) {
+ signatureHeader = req.Header.Get("Signature")
+ digestHeader = req.Header.Get("Digest")
+ dateHeader = req.Header.Get("Date")
+ r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out
+ return &http.Response{
+ StatusCode: 200,
+ Body: r,
+ }, nil
+ },
+ }
+
+ // use the client to create a new transport
+ c := NewTestTransportController(client)
+ tp, err := c.NewTransport(pubKeyID, privkey)
+ if err != nil {
+ panic(err)
+ }
+
+ // convert the activity into json bytes
+ m, err := activity.Serialize()
+ if err != nil {
+ panic(err)
+ }
+ bytes, err := json.Marshal(m)
+ if err != nil {
+ panic(err)
+ }
+
+ // trigger the delivery function, which will trigger the 'do' function of the recorder above
+ if err := tp.Deliver(context.Background(), bytes, destination); err != nil {
+ panic(err)
+ }
+
+ // headers should now be populated
+ return
+}
+
+// getSignatureForDereference does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive
+// the HTTP Signature for the given derefence GET request using public key ID, private key, and destination.
+func getSignatureForDereference(pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) {
+ // create a client that basically just pulls the signature out of the request and sets it
+ client := &mockHTTPClient{
+ do: func(req *http.Request) (*http.Response, error) {
+ signatureHeader = req.Header.Get("Signature")
+ digestHeader = req.Header.Get("Digest")
+ dateHeader = req.Header.Get("Date")
+ r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out
+ return &http.Response{
+ StatusCode: 200,
+ Body: r,
+ }, nil
+ },
+ }
+
+ // use the client to create a new transport
+ c := NewTestTransportController(client)
+ tp, err := c.NewTransport(pubKeyID, privkey)
+ if err != nil {
+ panic(err)
+ }
+
+ // trigger the delivery function, which will trigger the 'do' function of the recorder above
+ if _, err := tp.Dereference(context.Background(), destination); err != nil {
+ panic(err)
+ }
+
+ // headers should now be populated
+ return
+}
+
+func newPerson(
+ profileIDURI *url.URL,
+ followingURI *url.URL,
+ followersURI *url.URL,
+ inboxURI *url.URL,
+ outboxURI *url.URL,
+ featuredURI *url.URL,
+ username string,
+ displayName string,
+ note string,
+ profileURL *url.URL,
+ discoverable bool,
+ publicKeyURI *url.URL,
+ pkey *rsa.PublicKey,
+ avatarURL *url.URL,
+ avatarContentType string,
+ headerURL *url.URL,
+ headerContentType string) typeutils.Accountable {
+ person := streams.NewActivityStreamsPerson()
+
+ // id should be the activitypub URI of this user
+ // something like https://example.org/users/example_user
+ idProp := streams.NewJSONLDIdProperty()
+ idProp.SetIRI(profileIDURI)
+ person.SetJSONLDId(idProp)
+
+ // following
+ // The URI for retrieving a list of accounts this user is following
+ followingProp := streams.NewActivityStreamsFollowingProperty()
+ followingProp.SetIRI(followingURI)
+ person.SetActivityStreamsFollowing(followingProp)
+
+ // followers
+ // The URI for retrieving a list of this user's followers
+ followersProp := streams.NewActivityStreamsFollowersProperty()
+ followersProp.SetIRI(followersURI)
+ person.SetActivityStreamsFollowers(followersProp)
+
+ // inbox
+ // the activitypub inbox of this user for accepting messages
+ inboxProp := streams.NewActivityStreamsInboxProperty()
+ inboxProp.SetIRI(inboxURI)
+ person.SetActivityStreamsInbox(inboxProp)
+
+ // outbox
+ // the activitypub outbox of this user for serving messages
+ outboxProp := streams.NewActivityStreamsOutboxProperty()
+ outboxProp.SetIRI(outboxURI)
+ person.SetActivityStreamsOutbox(outboxProp)
+
+ // featured posts
+ // Pinned posts.
+ featuredProp := streams.NewTootFeaturedProperty()
+ featuredProp.SetIRI(featuredURI)
+ person.SetTootFeatured(featuredProp)
+
+ // featuredTags
+ // NOT IMPLEMENTED
+
+ // preferredUsername
+ // Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI.
+ preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty()
+ preferredUsernameProp.SetXMLSchemaString(username)
+ person.SetActivityStreamsPreferredUsername(preferredUsernameProp)
+
+ // name
+ // Used as profile display name.
+ nameProp := streams.NewActivityStreamsNameProperty()
+ if displayName != "" {
+ nameProp.AppendXMLSchemaString(displayName)
+ } else {
+ nameProp.AppendXMLSchemaString(username)
+ }
+ person.SetActivityStreamsName(nameProp)
+
+ // summary
+ // Used as profile bio.
+ if note != "" {
+ summaryProp := streams.NewActivityStreamsSummaryProperty()
+ summaryProp.AppendXMLSchemaString(note)
+ person.SetActivityStreamsSummary(summaryProp)
+ }
+
+ // url
+ // Used as profile link.
+ urlProp := streams.NewActivityStreamsUrlProperty()
+ urlProp.AppendIRI(profileURL)
+ person.SetActivityStreamsUrl(urlProp)
+
+ // manuallyApprovesFollowers
+ // Will be shown as a locked account.
+ // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool
+
+ // discoverable
+ // Will be shown in the profile directory.
+ discoverableProp := streams.NewTootDiscoverableProperty()
+ discoverableProp.Set(discoverable)
+ person.SetTootDiscoverable(discoverableProp)
+
+ // devices
+ // NOT IMPLEMENTED, probably won't implement
+
+ // alsoKnownAs
+ // Required for Move activity.
+ // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool
+
+ // publicKey
+ // Required for signatures.
+ publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
+
+ // create the public key
+ publicKey := streams.NewW3IDSecurityV1PublicKey()
+
+ // set ID for the public key
+ publicKeyIDProp := streams.NewJSONLDIdProperty()
+ publicKeyIDProp.SetIRI(publicKeyURI)
+ publicKey.SetJSONLDId(publicKeyIDProp)
+
+ // set owner for the public key
+ publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty()
+ publicKeyOwnerProp.SetIRI(profileIDURI)
+ publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp)
+
+ // set the pem key itself
+ encodedPublicKey, err := x509.MarshalPKIXPublicKey(pkey)
+ if err != nil {
+ panic(err)
+ }
+ publicKeyBytes := pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: encodedPublicKey,
+ })
+ publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
+ publicKeyPEMProp.Set(string(publicKeyBytes))
+ publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp)
+
+ // append the public key to the public key property
+ publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey)
+
+ // set the public key property on the Person
+ person.SetW3IDSecurityV1PublicKey(publicKeyProp)
+
+ // tag
+ // TODO: Any tags used in the summary of this profile
+
+ // attachment
+ // Used for profile fields.
+ // TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue
+
+ // endpoints
+ // NOT IMPLEMENTED -- this is for shared inbox which we don't use
+
+ // icon
+ // Used as profile avatar.
+ iconProperty := streams.NewActivityStreamsIconProperty()
+ iconImage := streams.NewActivityStreamsImage()
+ mediaType := streams.NewActivityStreamsMediaTypeProperty()
+ mediaType.Set(avatarContentType)
+ iconImage.SetActivityStreamsMediaType(mediaType)
+ avatarURLProperty := streams.NewActivityStreamsUrlProperty()
+ avatarURLProperty.AppendIRI(avatarURL)
+ iconImage.SetActivityStreamsUrl(avatarURLProperty)
+ iconProperty.AppendActivityStreamsImage(iconImage)
+ person.SetActivityStreamsIcon(iconProperty)
+
+ // image
+ // Used as profile header.
+ headerProperty := streams.NewActivityStreamsImageProperty()
+ headerImage := streams.NewActivityStreamsImage()
+ headerMediaType := streams.NewActivityStreamsMediaTypeProperty()
+ mediaType.Set(headerContentType)
+ headerImage.SetActivityStreamsMediaType(headerMediaType)
+ headerURLProperty := streams.NewActivityStreamsUrlProperty()
+ headerURLProperty.AppendIRI(headerURL)
+ headerImage.SetActivityStreamsUrl(headerURLProperty)
+ headerProperty.AppendActivityStreamsImage(headerImage)
+
+ return person
+}
+
+// newNote returns a new activity streams note for the given parameters
+func newNote(
+ noteID *url.URL,
+ noteURL *url.URL,
+ noteContent string,
+ noteSummary string,
+ noteAttributedTo *url.URL,
+ noteTo []*url.URL,
+ noteCC []*url.URL,
+ noteSensitive bool) vocab.ActivityStreamsNote {
+
+ // create the note itself
+ note := streams.NewActivityStreamsNote()
+
+ // set id
+ if noteID != nil {
+ id := streams.NewJSONLDIdProperty()
+ id.Set(noteID)
+ note.SetJSONLDId(id)
+ }
+
+ // set noteURL
+ if noteURL != nil {
+ url := streams.NewActivityStreamsUrlProperty()
+ url.AppendIRI(noteURL)
+ note.SetActivityStreamsUrl(url)
+ }
+
+ // set noteContent
+ if noteContent != "" {
+ content := streams.NewActivityStreamsContentProperty()
+ content.AppendXMLSchemaString(noteContent)
+ note.SetActivityStreamsContent(content)
+ }
+
+ // set noteSummary (aka content warning)
+ if noteSummary != "" {
+ summary := streams.NewActivityStreamsSummaryProperty()
+ summary.AppendXMLSchemaString(noteSummary)
+ note.SetActivityStreamsSummary(summary)
+ }
+
+ // set noteAttributedTo (the url of the author of the note)
+ if noteAttributedTo != nil {
+ attributedTo := streams.NewActivityStreamsAttributedToProperty()
+ attributedTo.AppendIRI(noteAttributedTo)
+ note.SetActivityStreamsAttributedTo(attributedTo)
+ }
+
+ return note
+}
+
+// wrapNoteInCreate wraps the given activity streams note in a Create activity streams action
+func wrapNoteInCreate(createID *url.URL, createActor *url.URL, createPublished time.Time, createNote vocab.ActivityStreamsNote) vocab.ActivityStreamsCreate {
+ // create the.... create
+ create := streams.NewActivityStreamsCreate()
+
+ // set createID
+ if createID != nil {
+ id := streams.NewJSONLDIdProperty()
+ id.Set(createID)
+ create.SetJSONLDId(id)
+ }
+
+ // set createActor
+ if createActor != nil {
+ actor := streams.NewActivityStreamsActorProperty()
+ actor.AppendIRI(createActor)
+ create.SetActivityStreamsActor(actor)
+ }
+
+ // set createPublished (time)
+ if !createPublished.IsZero() {
+ published := streams.NewActivityStreamsPublishedProperty()
+ published.Set(createPublished)
+ create.SetActivityStreamsPublished(published)
+ }
+
+ // setCreateTo
+ if createNote.GetActivityStreamsTo() != nil {
+ create.SetActivityStreamsTo(createNote.GetActivityStreamsTo())
+ }
+
+ // setCreateCC
+ if createNote.GetActivityStreamsCc() != nil {
+ create.SetActivityStreamsCc(createNote.GetActivityStreamsCc())
+ }
+
+ // set createNote
+ if createNote != nil {
+ note := streams.NewActivityStreamsObjectProperty()
+ note.AppendActivityStreamsNote(createNote)
+ create.SetActivityStreamsObject(note)
+ }
+
+ return create
+}
diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go
@@ -0,0 +1,73 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package testrig
+
+import (
+ "bytes"
+ "io/ioutil"
+ "net/http"
+
+ "github.com/go-fed/activity/pub"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/transport"
+)
+
+// NewTestTransportController returns a test transport controller with the given http client.
+//
+// Obviously for testing purposes you should not be making actual http calls to other servers.
+// To obviate this, use the function NewMockHTTPClient in this package to return a mock http
+// client that doesn't make any remote calls but just returns whatever you tell it to.
+//
+// Unlike the other test interfaces provided in this package, you'll probably want to call this function
+// PER TEST rather than per suite, so that the do function can be set on a test by test (or even more granular)
+// basis.
+func NewTestTransportController(client pub.HttpClient) transport.Controller {
+ return transport.NewController(NewTestConfig(), &federation.Clock{}, client, NewTestLog())
+}
+
+// NewMockHTTPClient returns a client that conforms to the pub.HttpClient interface,
+// but will always just execute the given `do` function, allowing responses to be mocked.
+//
+// If 'do' is nil, then a no-op function will be used instead, that just returns status 200.
+//
+// Note that you should never ever make ACTUAL http calls with this thing.
+func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error)) pub.HttpClient {
+ if do == nil {
+ return &mockHTTPClient{
+ do: func(req *http.Request) (*http.Response, error) {
+ r := ioutil.NopCloser(bytes.NewReader([]byte{}))
+ return &http.Response{
+ StatusCode: 200,
+ Body: r,
+ }, nil
+ },
+ }
+ }
+ return &mockHTTPClient{
+ do: do,
+ }
+}
+
+type mockHTTPClient struct {
+ do func(req *http.Request) (*http.Response, error)
+}
+
+func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
+ return m.do(req)
+}
diff --git a/testrig/typeconverter.go b/testrig/typeconverter.go
@@ -0,0 +1,29 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+package testrig
+
+import (
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+// NewTestTypeConverter returned a type converter with the given db and the default test config
+func NewTestTypeConverter(db db.DB) typeutils.TypeConverter {
+ return typeutils.NewConverter(NewTestConfig(), db)
+}
diff --git a/testrig/util.go b/testrig/util.go
@@ -22,6 +22,7 @@ import (
"bytes"
"io"
"mime/multipart"
+ "net/url"
"os"
)
@@ -62,3 +63,13 @@ func CreateMultipartFormData(fieldName string, fileName string, extraFields map[
}
return b, w, nil
}
+
+// URLMustParse tries to parse the given URL and panics if it can't.
+// Should only be used in tests.
+func URLMustParse(stringURL string) *url.URL {
+ u, err := url.Parse(stringURL)
+ if err != nil {
+ panic(err)
+ }
+ return u
+}