diff options
author | 2021-05-08 14:25:55 +0200 | |
---|---|---|
committer | 2021-05-08 14:25:55 +0200 | |
commit | 6f5c045284d34ba580d3007f70b97e05d6760527 (patch) | |
tree | 7614da22fba906361a918fb3527465b39272ac93 /internal/api | |
parent | Revert "make boosts work woo (#12)" (#15) (diff) | |
download | gotosocial-6f5c045284d34ba580d3007f70b97e05d6760527.tar.xz |
Ap (#14)
Big restructuring and initial work on activitypub
Diffstat (limited to 'internal/api')
80 files changed, 6149 insertions, 0 deletions
diff --git a/internal/api/apimodule.go b/internal/api/apimodule.go new file mode 100644 index 000000000..d0bcc612a --- /dev/null +++ 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 new file mode 100644 index 000000000..dce810202 --- /dev/null +++ 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 new file mode 100644 index 000000000..d0560bcb6 --- /dev/null +++ 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 new file mode 100644 index 000000000..b53d8c412 --- /dev/null +++ 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 new file mode 100644 index 000000000..da86ee940 --- /dev/null +++ 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 new file mode 100644 index 000000000..5ca17a167 --- /dev/null +++ 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 new file mode 100644 index 000000000..406769fe7 --- /dev/null +++ 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 new file mode 100644 index 000000000..ba7faa794 --- /dev/null +++ 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 new file mode 100644 index 000000000..4c62ff705 --- /dev/null +++ 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 new file mode 100644 index 000000000..85b0dce50 --- /dev/null +++ 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 new file mode 100644 index 000000000..7ce5311eb --- /dev/null +++ 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 new file mode 100644 index 000000000..0e60db65f --- /dev/null +++ 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 new file mode 100644 index 000000000..d1e732a8c --- /dev/null +++ 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 new file mode 100644 index 000000000..42760a2db --- /dev/null +++ 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 new file mode 100644 index 000000000..fd42482d4 --- /dev/null +++ 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 new file mode 100644 index 000000000..793c19f4e --- /dev/null +++ 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 new file mode 100644 index 000000000..7ec788a0e --- /dev/null +++ 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 new file mode 100644 index 000000000..d5f8ee214 --- /dev/null +++ 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 new file mode 100644 index 000000000..c42ba77fc --- /dev/null +++ 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 new file mode 100644 index 000000000..79d9b300e --- /dev/null +++ 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/api/client/auth/token.go b/internal/api/client/auth/token.go new file mode 100644 index 000000000..c531a3009 --- /dev/null +++ b/internal/api/client/auth/token.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 auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// TokenPOSTHandler should be served as a POST at https://example.org/oauth/token +// The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. +// See https://docs.joinmastodon.org/methods/apps/oauth/#obtain-a-token +func (m *Module) TokenPOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "TokenPOSTHandler") + l.Trace("entered TokenPOSTHandler") + if err := m.server.HandleTokenRequest(c.Writer, c.Request); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } +} diff --git a/internal/api/client/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go new file mode 100644 index 000000000..63d323a01 --- /dev/null +++ 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 new file mode 100644 index 000000000..9823eb387 --- /dev/null +++ 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 new file mode 100644 index 000000000..09fd8ea43 --- /dev/null +++ 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 new file mode 100644 index 000000000..2826783d6 --- /dev/null +++ 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 new file mode 100644 index 000000000..db57e2052 --- /dev/null +++ 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 new file mode 100644 index 000000000..e86c66021 --- /dev/null +++ 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 new file mode 100644 index 000000000..ba9295623 --- /dev/null +++ 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 new file mode 100644 index 000000000..0f77820a1 --- /dev/null +++ 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 new file mode 100644 index 000000000..02080b042 --- /dev/null +++ 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 new file mode 100644 index 000000000..fb9b48f8a --- /dev/null +++ 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 new file mode 100644 index 000000000..e55416522 --- /dev/null +++ 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 new file mode 100644 index 000000000..888589a8a --- /dev/null +++ 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 new file mode 100644 index 000000000..2f779baed --- /dev/null +++ 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 new file mode 100644 index 000000000..799acb7d2 --- /dev/null +++ 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 new file mode 100644 index 000000000..7b72df7bc --- /dev/null +++ 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 new file mode 100644 index 000000000..c6239cb36 --- /dev/null +++ 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 new file mode 100644 index 000000000..b31acebca --- /dev/null +++ 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 new file mode 100644 index 000000000..94fd662de --- /dev/null +++ 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 new file mode 100644 index 000000000..44b1dd3a6 --- /dev/null +++ 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 new file mode 100644 index 000000000..efb69d6fd --- /dev/null +++ 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 new file mode 100644 index 000000000..c1736a8d6 --- /dev/null +++ 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 new file mode 100644 index 000000000..036218f77 --- /dev/null +++ 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 new file mode 100644 index 000000000..eeb4b8720 --- /dev/null +++ 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 new file mode 100644 index 000000000..81118fef0 --- /dev/null +++ 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 new file mode 100644 index 000000000..a796c88ea --- /dev/null +++ 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 new file mode 100644 index 000000000..d90247f83 --- /dev/null +++ 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 new file mode 100644 index 000000000..ffa6d53e5 --- /dev/null +++ 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 new file mode 100644 index 000000000..4f004f13c --- /dev/null +++ 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 new file mode 100644 index 000000000..d0979319b --- /dev/null +++ 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 new file mode 100644 index 000000000..b0568c17e --- /dev/null +++ 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 new file mode 100644 index 000000000..c2834718f --- /dev/null +++ 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 new file mode 100644 index 000000000..f145d69f2 --- /dev/null +++ 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 new file mode 100644 index 000000000..3df3fe4c9 --- /dev/null +++ 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 new file mode 100644 index 000000000..2e7662b2b --- /dev/null +++ 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 new file mode 100644 index 000000000..519922ba3 --- /dev/null +++ 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 new file mode 100644 index 000000000..d8b4d6b4f --- /dev/null +++ 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 new file mode 100644 index 000000000..400835fca --- /dev/null +++ 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 new file mode 100644 index 000000000..857a8acc5 --- /dev/null +++ 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 new file mode 100644 index 000000000..220cde59e --- /dev/null +++ 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 new file mode 100644 index 000000000..1e39f1516 --- /dev/null +++ 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 new file mode 100644 index 000000000..a7985af24 --- /dev/null +++ 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 new file mode 100644 index 000000000..c8d080e2a --- /dev/null +++ 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 new file mode 100644 index 000000000..250d2218f --- /dev/null +++ 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 new file mode 100644 index 000000000..b00e7680a --- /dev/null +++ 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 new file mode 100644 index 000000000..9e410091e --- /dev/null +++ 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 new file mode 100644 index 000000000..f34c63374 --- /dev/null +++ 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 new file mode 100644 index 000000000..6e71023e2 --- /dev/null +++ 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 new file mode 100644 index 000000000..1b2625a0d --- /dev/null +++ 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 new file mode 100644 index 000000000..deafd22aa --- /dev/null +++ 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 new file mode 100644 index 000000000..441af71de --- /dev/null +++ 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 new file mode 100644 index 000000000..faf88ae84 --- /dev/null +++ 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 new file mode 100644 index 000000000..f009b4cef --- /dev/null +++ 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 new file mode 100644 index 000000000..611ab214c --- /dev/null +++ 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 new file mode 100644 index 000000000..693fac7c3 --- /dev/null +++ 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 new file mode 100644 index 000000000..84e35ab68 --- /dev/null +++ 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 new file mode 100644 index 000000000..8df137f44 --- /dev/null +++ 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 new file mode 100644 index 000000000..b45b01b63 --- /dev/null +++ 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/api/security/flocblock.go b/internal/api/security/flocblock.go new file mode 100644 index 000000000..7cedcde6b --- /dev/null +++ b/internal/api/security/flocblock.go @@ -0,0 +1,28 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package security + +import "github.com/gin-gonic/gin" + +// FlocBlock is a middleware that prevents google chrome cohort tracking by +// writing the Permissions-Policy header after all other parts of the request have been completed. +// See: https://plausible.io/blog/google-floc +func (m *Module) FlocBlock(c *gin.Context) { + c.Header("Permissions-Policy", "interest-cohort=()") +} diff --git a/internal/api/security/security.go b/internal/api/security/security.go new file mode 100644 index 000000000..c80b568b3 --- /dev/null +++ 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 +} |