gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit b4288f3c47a9ff9254b933dcb9ee7274d4a4135c
parent 6ac6f8d614d17910d929981bde7d80d8ec2c0b6e
Author: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date:   Sun, 13 Jun 2021 18:42:28 +0200

Timeline manager (#40)

* start messing about with timeline manager

* i have no idea what i'm doing

* i continue to not know what i'm doing

* it's coming along

* bit more progress

* update timeline with new posts as they come in

* lint and fmt

* Select accounts where empty string

* restructure a bunch, get unfaves working

* moving stuff around

* federate status deletes properly

* mention regex better but not 100% there

* fix regex

* some more hacking away at the timeline code phew

* fix up some little things

* i can't even

* more timeline stuff

* move to ulid

* fiddley

* some lil fixes for kibou compatibility

* timelines working pretty alright!

* tidy + lint
Diffstat:
Mgo.mod | 1+
Mgo.sum | 2++
Minternal/api/client/account/accountcreate_test.go | 369-------------------------------------------------------------------------------
Minternal/api/client/auth/signin.go | 2+-
Minternal/api/client/emoji/emojisget.go | 8++++++--
Minternal/api/client/fileserver/fileserver.go | 2+-
Minternal/api/client/filter/filtersget.go | 8++++++--
Minternal/api/client/list/listsgets.go | 8++++++--
Minternal/api/client/status/statusdelete.go | 6++++++
Minternal/api/client/timeline/home.go | 7++++---
Ainternal/api/model/timeline.go | 8++++++++
Minternal/api/s2s/user/inboxpost.go | 7++++---
Minternal/api/s2s/webfinger/webfingerget.go | 11+++++++++++
Ainternal/api/security/robots.go | 17+++++++++++++++++
Minternal/api/security/security.go | 5+++++
Minternal/api/security/useragentblock.go | 12++++++++----
Minternal/cliactions/server/server.go | 32+++++++++++++++++---------------
Minternal/db/db.go | 15++++++---------
Minternal/db/pg/pg.go | 283++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Minternal/federation/federatingdb/accept.go | 37+++++++++++++++++++++++++++++++++++++
Minternal/federation/federatingdb/create.go | 24++++++++++++++++++++++++
Minternal/federation/federatingdb/followers.go | 2+-
Minternal/federation/federatingdb/undo.go | 3+++
Minternal/federation/federatingdb/util.go | 18+++++++++++++-----
Minternal/federation/federatingprotocol.go | 7+++++++
Ainternal/gtserror/withcode.go | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/gtsmodel/README.md | 5-----
Minternal/gtsmodel/account.go | 10+++++-----
Minternal/gtsmodel/application.go | 4++--
Minternal/gtsmodel/block.go | 6+++---
Minternal/gtsmodel/domainblock.go | 4++--
Minternal/gtsmodel/emaildomainblock.go | 4++--
Minternal/gtsmodel/emoji.go | 4++--
Minternal/gtsmodel/follow.go | 6+++---
Minternal/gtsmodel/followrequest.go | 6+++---
Minternal/gtsmodel/instance.go | 6+++---
Minternal/gtsmodel/mediaattachment.go | 8++++----
Minternal/gtsmodel/mention.go | 12++++++++----
Minternal/gtsmodel/notification.go | 8++++----
Minternal/gtsmodel/status.go | 13+++++++------
Minternal/gtsmodel/statusbookmark.go | 8++++----
Minternal/gtsmodel/statusfave.go | 8++++----
Minternal/gtsmodel/statusmute.go | 8++++----
Minternal/gtsmodel/tag.go | 4++--
Minternal/gtsmodel/user.go | 8++++----
Ainternal/id/ulid.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/media/handler.go | 10++++++----
Minternal/media/processicon.go | 9++++++---
Minternal/media/processimage.go | 9++++++---
Minternal/oauth/clientstore.go | 2+-
Minternal/oauth/tokenstore.go | 15+++++++++++++--
Minternal/processing/account.go | 99+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Minternal/processing/admin.go | 7+++++++
Minternal/processing/app.go | 12+++++++++++-
Dinternal/processing/error.go | 124-------------------------------------------------------------------------------
Minternal/processing/federation.go | 77++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Minternal/processing/followrequest.go | 23++++++++++++-----------
Minternal/processing/fromclientapi.go | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Minternal/processing/fromcommon.go | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/fromfederator.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++------
Minternal/processing/instance.go | 7++++---
Minternal/processing/media.go | 55++++++++++++++++++++++++++++---------------------------
Minternal/processing/notification.go | 5+++--
Minternal/processing/processor.go | 140+++++++++++++++++++++++++++++++++++++------------------------------------------
Minternal/processing/search.go | 27+++++++++++++++++++++++----
Minternal/processing/status.go | 516+++----------------------------------------------------------------------------
Ainternal/processing/synchronous/status/boost.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/boostedby.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/context.go | 14++++++++++++++
Ainternal/processing/synchronous/status/create.go | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/delete.go | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/fave.go | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/favedby.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/get.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/status.go | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/unfave.go | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/synchronous/status/util.go | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/timeline.go | 168++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Minternal/processing/util.go | 222-------------------------------------------------------------------------------
Minternal/router/router.go | 12+++++++-----
Ainternal/timeline/get.go | 309+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/timeline/index.go | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/timeline/manager.go | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/timeline/postindex.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/timeline/prepare.go | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/timeline/preparedposts.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/timeline/remove.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/timeline/timeline.go | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/typeutils/astointernal.go | 11+++++++----
Minternal/typeutils/internal.go | 9++++++---
Minternal/typeutils/internaltofrontend.go | 2+-
Minternal/typeutils/wrap.go | 10++++++++--
Minternal/util/regexes.go | 24++++++++++++++----------
Minternal/util/statustools.go | 27+++------------------------
Minternal/util/uri.go | 46+++++++++++++++++++++++++---------------------
Mtestrig/processor.go | 2+-
Atestrig/timelinemanager.go | 11+++++++++++
97 files changed, 3560 insertions(+), 1781 deletions(-)

diff --git a/go.mod b/go.mod @@ -33,6 +33,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/oklog/ulid v1.3.1 github.com/onsi/gomega v1.13.0 // indirect github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum @@ -204,6 +204,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0= diff --git a/internal/api/client/account/accountcreate_test.go b/internal/api/client/account/accountcreate_test.go @@ -17,372 +17,3 @@ // */ 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 := &gtsmodel.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 := &gtsmodel.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/auth/signin.go b/internal/api/client/auth/signin.go @@ -74,7 +74,7 @@ func (m *Module) SignInPOSTHandler(c *gin.Context) { // 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, +// address stored in the database. If OK, we return the userid (a ulid) 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") diff --git a/internal/api/client/emoji/emojisget.go b/internal/api/client/emoji/emojisget.go @@ -1,8 +1,12 @@ package emoji -import "github.com/gin-gonic/gin" +import ( + "net/http" + + "github.com/gin-gonic/gin" +) // EmojisGETHandler returns a list of custom emojis enabled on the instance func (m *Module) EmojisGETHandler(c *gin.Context) { - + c.JSON(http.StatusOK, []string{}) } diff --git a/internal/api/client/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go @@ -32,7 +32,7 @@ import ( ) const ( - // AccountIDKey is the url key for account id (an account uuid) + // AccountIDKey is the url key for account id (an account ulid) AccountIDKey = "account_id" // MediaTypeKey is the url key for media type (usually something like attachment or header etc) MediaTypeKey = "media_type" diff --git a/internal/api/client/filter/filtersget.go b/internal/api/client/filter/filtersget.go @@ -1,8 +1,12 @@ package filter -import "github.com/gin-gonic/gin" +import ( + "net/http" + + "github.com/gin-gonic/gin" +) // FiltersGETHandler returns a list of filters set by/for the authed account func (m *Module) FiltersGETHandler(c *gin.Context) { - + c.JSON(http.StatusOK, []string{}) } diff --git a/internal/api/client/list/listsgets.go b/internal/api/client/list/listsgets.go @@ -1,8 +1,12 @@ package list -import "github.com/gin-gonic/gin" +import ( + "net/http" + + "github.com/gin-gonic/gin" +) // ListsGETHandler returns a list of lists created by/for the authed account func (m *Module) ListsGETHandler(c *gin.Context) { - + c.JSON(http.StatusOK, []string{}) } diff --git a/internal/api/client/status/statusdelete.go b/internal/api/client/status/statusdelete.go @@ -56,5 +56,11 @@ func (m *Module) StatusDELETEHandler(c *gin.Context) { return } + // the status was already gone/never existed + if mastoStatus == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"}) + return + } + c.JSON(http.StatusOK, mastoStatus) } diff --git a/internal/api/client/timeline/home.go b/internal/api/client/timeline/home.go @@ -87,12 +87,13 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) { local = i } - statuses, errWithCode := m.processor.HomeTimelineGet(authed, maxID, sinceID, minID, limit, local) + resp, errWithCode := m.processor.HomeTimelineGet(authed, maxID, sinceID, minID, limit, local) if errWithCode != nil { - l.Debugf("error from processor account statuses get: %s", errWithCode) + l.Debugf("error from processor HomeTimelineGet: %s", errWithCode) c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) return } - c.JSON(http.StatusOK, statuses) + c.Header("Link", resp.LinkHeader) + c.JSON(http.StatusOK, resp.Statuses) } diff --git a/internal/api/model/timeline.go b/internal/api/model/timeline.go @@ -0,0 +1,8 @@ +package model + +// StatusTimelineResponse wraps a slice of statuses, ready to be serialized, along with the Link +// header for the previous and next queries, to be returned to the client. +type StatusTimelineResponse struct { + Statuses []*Status + LinkHeader string +} diff --git a/internal/api/s2s/user/inboxpost.go b/internal/api/s2s/user/inboxpost.go @@ -23,7 +23,7 @@ import ( "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // InboxPOSTHandler deals with incoming POST requests to an actor's inbox. @@ -42,17 +42,18 @@ func (m *Module) InboxPOSTHandler(c *gin.Context) { posted, err := m.processor.InboxPost(c.Request.Context(), c.Writer, c.Request) if err != nil { - if withCode, ok := err.(processing.ErrorWithCode); ok { + if withCode, ok := err.(gtserror.WithCode); ok { l.Debug(withCode.Error()) c.JSON(withCode.Code(), withCode.Safe()) return } - l.Debug(err) + l.Debugf("InboxPOSTHandler: error processing request: %s", err) c.JSON(http.StatusBadRequest, gin.H{"error": "unable to process request"}) return } if !posted { + l.Debugf("request could not be handled as an AP request; headers were: %+v", c.Request.Header) c.JSON(http.StatusBadRequest, gin.H{"error": "unable to process request"}) } } diff --git a/internal/api/s2s/webfinger/webfingerget.go b/internal/api/s2s/webfinger/webfingerget.go @@ -24,42 +24,53 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) // WebfingerGETRequest handles requests to, for example, https://example.org/.well-known/webfinger?resource=acct:some_user@example.org func (m *Module) WebfingerGETRequest(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "WebfingerGETRequest", + "user-agent": c.Request.UserAgent(), + }) q, set := c.GetQuery("resource") if !set || q == "" { + l.Debug("aborting request because no resource was set in query") c.JSON(http.StatusBadRequest, gin.H{"error": "no 'resource' in request query"}) return } withAcct := strings.Split(q, "acct:") if len(withAcct) != 2 { + l.Debugf("aborting request because resource query %s could not be split by 'acct:'", q) c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) return } usernameDomain := strings.Split(withAcct[1], "@") if len(usernameDomain) != 2 { + l.Debugf("aborting request because username and domain could not be parsed from %s", withAcct[1]) c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) return } username := strings.ToLower(usernameDomain[0]) domain := strings.ToLower(usernameDomain[1]) if username == "" || domain == "" { + l.Debug("aborting request because username or domain was empty") c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) return } if domain != m.config.Host { + l.Debugf("aborting request because domain %s does not belong to this instance", domain) c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("domain %s does not belong to this instance", domain)}) return } resp, err := m.processor.GetWebfingerAccount(username, c.Request) if err != nil { + l.Debugf("aborting request with an error: %s", err.Error()) c.JSON(err.Code(), gin.H{"error": err.Safe()}) return } diff --git a/internal/api/security/robots.go b/internal/api/security/robots.go @@ -0,0 +1,17 @@ +package security + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +const robotsString = `User-agent: * +Disallow: / +` + +// RobotsGETHandler returns the most restrictive possible robots.txt file in response to a call to /robots.txt. +// The response instructs bots with *any* user agent not to index the instance at all. +func (m *Module) RobotsGETHandler(c *gin.Context) { + c.String(http.StatusOK, robotsString) +} diff --git a/internal/api/security/security.go b/internal/api/security/security.go @@ -19,12 +19,16 @@ package security import ( + "net/http" + "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/router" ) +const robotsPath = "/robots.txt" + // Module implements the ClientAPIModule interface for security middleware type Module struct { config *config.Config @@ -44,5 +48,6 @@ func (m *Module) Route(s router.Router) error { s.AttachMiddleware(m.FlocBlock) s.AttachMiddleware(m.ExtraHeaders) s.AttachMiddleware(m.UserAgentBlock) + s.AttachHandler(http.MethodGet, robotsPath, m.RobotsGETHandler) return nil } diff --git a/internal/api/security/useragentblock.go b/internal/api/security/useragentblock.go @@ -23,20 +23,24 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) -// UserAgentBlock 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 +// UserAgentBlock blocks requests with undesired, empty, or invalid user-agent strings. func (m *Module) UserAgentBlock(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "UserAgentBlock", + }) ua := c.Request.UserAgent() if ua == "" { + l.Debug("aborting request because there's no user-agent set") c.AbortWithStatus(http.StatusTeapot) return } - if strings.Contains(strings.ToLower(c.Request.UserAgent()), strings.ToLower("friendica")) { + if strings.Contains(strings.ToLower(ua), strings.ToLower("friendica")) { + l.Debugf("aborting request with user-agent %s because it contains 'friendica'", ua) c.AbortWithStatus(http.StatusTeapot) return } diff --git a/internal/cliactions/server/server.go b/internal/cliactions/server/server.go @@ -40,6 +40,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/router" + timelineprocessing "github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -74,6 +75,20 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config, log return fmt.Errorf("error creating dbservice: %s", err) } + for _, m := range models { + if err := dbService.CreateTable(m); err != nil { + return fmt.Errorf("table creation error: %s", err) + } + } + + if err := dbService.CreateInstanceAccount(); err != nil { + return fmt.Errorf("error creating instance account: %s", err) + } + + if err := dbService.CreateInstanceInstance(); err != nil { + return fmt.Errorf("error creating instance instance: %s", err) + } + federatingDB := federatingdb.New(dbService, c, log) router, err := router.New(c, log) @@ -88,13 +103,14 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config, log // build converters and util typeConverter := typeutils.NewConverter(c, dbService) + timelineManager := timelineprocessing.NewManager(dbService, typeConverter, c, log) // build backend handlers mediaHandler := media.New(c, dbService, storageBackend, log) oauthServer := oauth.New(dbService, log) transportController := transport.NewController(c, &federation.Clock{}, http.DefaultClient, log) federator := federation.NewFederator(dbService, federatingDB, transportController, c, log, typeConverter) - processor := processing.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storageBackend, dbService, log) + processor := processing.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storageBackend, timelineManager, dbService, log) if err := processor.Start(); err != nil { return fmt.Errorf("error starting processor: %s", err) } @@ -149,20 +165,6 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config, log } } - for _, m := range models { - if err := dbService.CreateTable(m); err != nil { - return fmt.Errorf("table creation error: %s", err) - } - } - - if err := dbService.CreateInstanceAccount(); err != nil { - return fmt.Errorf("error creating instance account: %s", err) - } - - if err := dbService.CreateInstanceInstance(); err != nil { - return fmt.Errorf("error creating instance instance: %s", err) - } - gts, err := gotosocial.NewServer(dbService, router, federator, c) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) diff --git a/internal/db/db.go b/internal/db/db.go @@ -143,7 +143,9 @@ type DB interface { // GetFollowersByAccountID is a shortcut for the common action of fetching a list of accounts that accountID is followed by. // The given slice 'followers' will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned - GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error + // + // If localOnly is set to true, then only followers from *this instance* will be returned. + GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow, localOnly bool) error // GetFavesByAccountID is a shortcut for the common action of fetching a list of faves made by the given accountID. // The given slice 'faves' will be set to the result of the query, whatever it is. @@ -210,7 +212,7 @@ type DB interface { // 3. Accounts boosted by the target status // // Will return an error if something goes wrong while pulling stuff out of the database. - StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) + StatusVisible(targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) // Follows returns true if sourceAccount follows target account, or an error if something goes wrong while finding out. Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) @@ -245,10 +247,6 @@ type DB interface { // StatusBookmarkedBy checks if a given status has been bookmarked by a given account ID StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) - // UnfaveStatus unfaves the given status, using accountID as the unfaver (sure, that's a word). - // The returned fave will be nil if the status was already not faved. - UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) - // WhoFavedStatus returns a slice of accounts who faved the given status. // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) @@ -257,9 +255,8 @@ type DB interface { // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. WhoBoostedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) - // GetHomeTimelineForAccount fetches the account's HOME timeline -- ie., posts and replies from people they *follow*. - // It will use the given filters and try to return as many statuses up to the limit as possible. - GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) + // GetStatusesWhereFollowing returns a slice of statuses from accounts that are followed by the given account id. + GetStatusesWhereFollowing(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) // GetPublicTimelineForAccount fetches the account's PUBLIC timline -- ie., posts and replies that are public. // It will use the given filters and try to return as many statuses as possible up to the limit. diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go @@ -33,11 +33,11 @@ import ( "github.com/go-pg/pg/extra/pgdebug" "github.com/go-pg/pg/v10" "github.com/go-pg/pg/v10/orm" - "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/util" "golang.org/x/crypto/bcrypt" ) @@ -223,12 +223,16 @@ func (ps *postgresService) GetWhere(where []db.Where, i interface{}) error { q := ps.conn.Model(i) for _, w := range where { - if w.CaseInsensitive { - q = q.Where("LOWER(?) = LOWER(?)", pg.Safe(w.Key), w.Value) + + if w.Value == nil { + q = q.Where("? IS NULL", pg.Ident(w.Key)) } else { - q = q.Where("? = ?", pg.Safe(w.Key), w.Value) + if w.CaseInsensitive { + q = q.Where("LOWER(?) = LOWER(?)", pg.Safe(w.Key), w.Value) + } else { + q = q.Where("? = ?", pg.Safe(w.Key), w.Value) + } } - } if err := q.Select(); err != nil { @@ -240,10 +244,6 @@ func (ps *postgresService) GetWhere(where []db.Where, i interface{}) error { return nil } -// func (ps *postgresService) GetWhereMany(i interface{}, where ...model.Where) error { -// return nil -// } - func (ps *postgresService) GetAll(i interface{}) error { if err := ps.conn.Model(i).Select(); err != nil { if err == pg.ErrNoRows { @@ -334,6 +334,7 @@ func (ps *postgresService) AcceptFollowRequest(originAccountID string, targetAcc // create a new follow to 'replace' the request with follow := &gtsmodel.Follow{ + ID: fr.ID, AccountID: originAccountID, TargetAccountID: targetAccountID, URI: fr.URI, @@ -360,8 +361,14 @@ func (ps *postgresService) CreateInstanceAccount() error { return err } + aID, err := id.NewRandomULID() + if err != nil { + return err + } + newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) a := &gtsmodel.Account{ + ID: aID, Username: ps.config.Host, DisplayName: username, URL: newAccountURIs.UserURL, @@ -389,7 +396,13 @@ func (ps *postgresService) CreateInstanceAccount() error { } func (ps *postgresService) CreateInstanceInstance() error { + iID, err := id.NewRandomULID() + if err != nil { + return err + } + i := &gtsmodel.Instance{ + ID: iID, Domain: ps.config.Host, Title: ps.config.Host, URI: fmt.Sprintf("%s://%s", ps.config.Protocol, ps.config.Host), @@ -455,8 +468,28 @@ func (ps *postgresService) GetFollowingByAccountID(accountID string, following * return nil } -func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error { - if err := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil { +func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow, localOnly bool) error { + + q := ps.conn.Model(followers) + + if localOnly { + // for local accounts let's get where domain is null OR where domain is an empty string, just to be safe + whereGroup := func(q *pg.Query) (*pg.Query, error) { + q = q. + WhereOr("? IS NULL", pg.Ident("a.domain")). + WhereOr("a.domain = ?", "") + return q, nil + } + + q = q.ColumnExpr("follow.*"). + Join("JOIN accounts AS a ON follow.account_id = TEXT(a.id)"). + Where("follow.target_account_id = ?", accountID). + WhereGroup(whereGroup) + } else { + q = q.Where("target_account_id = ?", accountID) + } + + if err := q.Select(); err != nil { if err == pg.ErrNoRows { return nil } @@ -580,8 +613,13 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr } newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) + newAccountID, err := id.NewRandomULID() + if err != nil { + return nil, err + } a := &gtsmodel.Account{ + ID: newAccountID, Username: username, DisplayName: username, Reason: reason, @@ -605,8 +643,15 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr if err != nil { return nil, fmt.Errorf("error hashing password: %s", err) } + + newUserID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + u := &gtsmodel.User{ - AccountID: a.ID, + ID: newUserID, + AccountID: newAccountID, EncryptedPassword: string(pw), SignUpIP: signUpIP, Locale: locale, @@ -761,12 +806,14 @@ func (ps *postgresService) GetRelationship(requestingAccount string, targetAccou return r, nil } -func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) { +func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) { l := ps.log.WithField("func", "StatusVisible") + targetAccount := relevantAccounts.StatusAuthor + // if target account is suspended then don't show the status if !targetAccount.SuspendedAt.IsZero() { - l.Debug("target account suspended at is not zero") + l.Trace("target account suspended at is not zero") return false, nil } @@ -785,7 +832,7 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc // if target user is disabled, not yet approved, or not confirmed then don't show the status // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!) if targetUser.Disabled || !targetUser.Approved || targetUser.ConfirmedAt.IsZero() { - l.Debug("target user is disabled, not approved, or not confirmed") + l.Trace("target user is disabled, not approved, or not confirmed") return false, nil } } @@ -793,18 +840,17 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed. // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't. if requestingAccount == nil { - if targetStatus.Visibility == gtsmodel.VisibilityPublic { return true, nil } - l.Debug("requesting account is nil but the target status isn't public") + l.Trace("requesting account is nil but the target status isn't public") return false, nil } // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten // this far (ie., been authed) in the first place: this is just for safety. if !requestingAccount.SuspendedAt.IsZero() { - l.Debug("requesting account is suspended") + l.Trace("requesting account is suspended") return false, nil } @@ -822,7 +868,7 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc } // okay, user exists, so make sure it has full privileges/is confirmed/approved if requestingUser.Disabled || !requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() { - l.Debug("requesting account is local but corresponding user is either disabled, not approved, or not confirmed") + l.Trace("requesting account is local but corresponding user is either disabled, not approved, or not confirmed") return false, nil } } @@ -839,20 +885,32 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc return false, err } else if blocked { // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please - l.Debug("a block exists between requesting account and target account") + l.Trace("a block exists between requesting account and target account") return false, nil } // check other accounts mentioned/boosted by/replied to by the status, if they exist if relevantAccounts != nil { // status replies to account id - if relevantAccounts.ReplyToAccount != nil { + if relevantAccounts.ReplyToAccount != nil && relevantAccounts.ReplyToAccount.ID != requestingAccount.ID { if blocked, err := ps.Blocked(relevantAccounts.ReplyToAccount.ID, requestingAccount.ID); err != nil { return false, err } else if blocked { - l.Debug("a block exists between requesting account and reply to account") + l.Trace("a block exists between requesting account and reply to account") return false, nil } + + // check reply to ID + if targetStatus.InReplyToID != "" { + followsRepliedAccount, err := ps.Follows(requestingAccount, relevantAccounts.ReplyToAccount) + if err != nil { + return false, err + } + if !followsRepliedAccount { + l.Trace("target status is a followers-only reply to an account that is not followed by the requesting account") + return false, nil + } + } } // status boosts accounts id @@ -860,7 +918,7 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc if blocked, err := ps.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil { return false, err } else if blocked { - l.Debug("a block exists between requesting account and boosted account") + l.Trace("a block exists between requesting account and boosted account") return false, nil } } @@ -870,7 +928,7 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc if blocked, err := ps.Blocked(relevantAccounts.BoostedReplyToAccount.ID, requestingAccount.ID); err != nil { return false, err } else if blocked { - l.Debug("a block exists between requesting account and boosted reply to account") + l.Trace("a block exists between requesting account and boosted reply to account") return false, nil } } @@ -880,7 +938,7 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc if blocked, err := ps.Blocked(a.ID, requestingAccount.ID); err != nil { return false, err } else if blocked { - l.Debug("a block exists between requesting account and a mentioned account") + l.Trace("a block exists between requesting account and a mentioned account") return false, nil } } @@ -906,7 +964,7 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc return false, err } if !follows { - l.Debug("requested status is followers only but requesting account is not a follower") + l.Trace("requested status is followers only but requesting account is not a follower") return false, nil } return true, nil @@ -917,12 +975,12 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc return false, err } if !mutuals { - l.Debug("requested status is mutuals only but accounts aren't mufos") + l.Trace("requested status is mutuals only but accounts aren't mufos") return false, nil } return true, nil case gtsmodel.VisibilityDirect: - l.Debug("requesting account requests a status it's not mentioned in") + l.Trace("requesting account requests a status it's not mentioned in") return false, nil // it's not mentioned -_- } @@ -964,6 +1022,16 @@ func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel MentionedAccounts: []*gtsmodel.Account{}, } + // get the author account + if targetStatus.GTSAuthorAccount == nil { + statusAuthor := &gtsmodel.Account{} + if err := ps.conn.Model(statusAuthor).Where("id = ?", targetStatus.AccountID).Select(); err != nil { + return accounts, fmt.Errorf("PullRelevantAccountsFromStatus: error getting statusAuthor with id %s: %s", targetStatus.AccountID, err) + } + targetStatus.GTSAuthorAccount = statusAuthor + } + accounts.StatusAuthor = targetStatus.GTSAuthorAccount + // get the replied to account from the status and add it to the pile if targetStatus.InReplyToAccountID != "" { repliedToAccount := &gtsmodel.Account{} @@ -1042,55 +1110,6 @@ func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID return ps.conn.Model(&gtsmodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() } -// func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { -// // first check if a fave already exists, we can just return if so -// existingFave := &gtsmodel.StatusFave{} -// err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() -// if err == nil { -// // fave already exists so just return nothing at all -// return nil, nil -// } - -// // an error occurred so it might exist or not, we don't know -// if err != pg.ErrNoRows { -// return nil, err -// } - -// // it doesn't exist so create it -// newFave := &gtsmodel.StatusFave{ -// AccountID: accountID, -// TargetAccountID: status.AccountID, -// StatusID: status.ID, -// } -// if _, err = ps.conn.Model(newFave).Insert(); err != nil { -// return nil, err -// } - -// return newFave, nil -// } - -func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { - // if a fave doesn't exist, we don't need to do anything - existingFave := &gtsmodel.StatusFave{} - err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() - // the fave doesn't exist so return nothing at all - if err == pg.ErrNoRows { - return nil, nil - } - - // an error occurred so it might exist or not, we don't know - if err != nil && err != pg.ErrNoRows { - return nil, err - } - - // the fave exists so remove it - if _, err = ps.conn.Model(&gtsmodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Delete(); err != nil { - return nil, err - } - - return existingFave, nil -} - func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) { accounts := []*gtsmodel.Account{} @@ -1139,7 +1158,7 @@ func (ps *postgresService) WhoBoostedStatus(status *gtsmodel.Status) ([]*gtsmode return accounts, nil } -func (ps *postgresService) GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) { +func (ps *postgresService) GetStatusesWhereFollowing(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) { statuses := []*gtsmodel.Status{} q := ps.conn.Model(&statuses) @@ -1147,38 +1166,38 @@ func (ps *postgresService) GetHomeTimelineForAccount(accountID string, maxID str q = q.ColumnExpr("status.*"). Join("JOIN follows AS f ON f.target_account_id = status.account_id"). Where("f.account_id = ?", accountID). - Limit(limit). - Order("status.created_at DESC") + Order("status.id DESC") if maxID != "" { - s := &gtsmodel.Status{} - if err := ps.conn.Model(s).Where("id = ?", maxID).Select(); err != nil { - return nil, err - } - q = q.Where("status.created_at < ?", s.CreatedAt) + q = q.Where("status.id < ?", maxID) + } + + if sinceID != "" { + q = q.Where("status.id > ?", sinceID) } if minID != "" { - s := &gtsmodel.Status{} - if err := ps.conn.Model(s).Where("id = ?", minID).Select(); err != nil { - return nil, err - } - q = q.Where("status.created_at > ?", s.CreatedAt) + q = q.Where("status.id > ?", minID) } - if sinceID != "" { - s := &gtsmodel.Status{} - if err := ps.conn.Model(s).Where("id = ?", sinceID).Select(); err != nil { - return nil, err - } - q = q.Where("status.created_at > ?", s.CreatedAt) + if local { + q = q.Where("status.local = ?", local) + } + + if limit > 0 { + q = q.Limit(limit) } err := q.Select() if err != nil { - if err != pg.ErrNoRows { - return nil, err + if err == pg.ErrNoRows { + return nil, db.ErrNoEntries{} } + return nil, err + } + + if len(statuses) == 0 { + return nil, db.ErrNoEntries{} } return statuses, nil @@ -1189,42 +1208,36 @@ func (ps *postgresService) GetPublicTimelineForAccount(accountID string, maxID s q := ps.conn.Model(&statuses). Where("visibility = ?", gtsmodel.VisibilityPublic). - Limit(limit). - Order("created_at DESC") + Where("? IS NULL", pg.Ident("in_reply_to_id")). + Where("? IS NULL", pg.Ident("boost_of_id")). + Order("status.id DESC") if maxID != "" { - s := &gtsmodel.Status{} - if err := ps.conn.Model(s).Where("id = ?", maxID).Select(); err != nil { - return nil, err - } - q = q.Where("created_at < ?", s.CreatedAt) + q = q.Where("status.id < ?", maxID) } - if minID != "" { - s := &gtsmodel.Status{} - if err := ps.conn.Model(s).Where("id = ?", minID).Select(); err != nil { - return nil, err - } - q = q.Where("created_at > ?", s.CreatedAt) + if sinceID != "" { + q = q.Where("status.id > ?", sinceID) } - if sinceID != "" { - s := &gtsmodel.Status{} - if err := ps.conn.Model(s).Where("id = ?", sinceID).Select(); err != nil { - return nil, err - } - q = q.Where("created_at > ?", s.CreatedAt) + if minID != "" { + q = q.Where("status.id > ?", minID) } if local { - q = q.Where("local = ?", local) + q = q.Where("status.local = ?", local) + } + + if limit > 0 { + q = q.Limit(limit) } err := q.Select() if err != nil { - if err != pg.ErrNoRows { - return nil, err + if err == pg.ErrNoRows { + return nil, db.ErrNoEntries{} } + return nil, err } return statuses, nil @@ -1236,19 +1249,11 @@ func (ps *postgresService) GetNotificationsForAccount(accountID string, limit in q := ps.conn.Model(&notifications).Where("target_account_id = ?", accountID) if maxID != "" { - n := &gtsmodel.Notification{} - if err := ps.conn.Model(n).Where("id = ?", maxID).Select(); err != nil { - return nil, err - } - q = q.Where("created_at < ?", n.CreatedAt) + q = q.Where("id < ?", maxID) } if sinceID != "" { - n := &gtsmodel.Notification{} - if err := ps.conn.Model(n).Where("id = ?", sinceID).Select(); err != nil { - return nil, err - } - q = q.Where("created_at > ?", n.CreatedAt) + q = q.Where("id > ?", sinceID) } if limit != 0 { @@ -1270,6 +1275,8 @@ func (ps *postgresService) GetNotificationsForAccount(accountID string, limit in CONVERSION FUNCTIONS */ +// TODO: move these to the type converter, it's bananas that they're here and not there + func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { ogAccount := &gtsmodel.Account{} if err := ps.conn.Model(ogAccount).Where("id = ?", originAccountID).Select(); err != nil { @@ -1313,12 +1320,14 @@ func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, ori // okay we're good now, we can start pulling accounts out of the database mentionedAccount := &gtsmodel.Account{} var err error + + // match username + account, case insensitive if local { // local user -- should have a null domain - err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select() + err = ps.conn.Model(mentionedAccount).Where("LOWER(?) = LOWER(?)", pg.Ident("username"), username).Where("? IS NULL", pg.Ident("domain")).Select() } else { // remote user -- should have domain defined - err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? = ?", pg.Ident("domain"), domain).Select() + err = ps.conn.Model(mentionedAccount).Where("LOWER(?) = LOWER(?)", pg.Ident("username"), username).Where("LOWER(?) = LOWER(?)", pg.Ident("domain"), domain).Select() } if err != nil { @@ -1339,6 +1348,7 @@ func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, ori TargetAccountID: mentionedAccount.ID, NameString: a, MentionedAccountURI: mentionedAccount.URI, + MentionedAccountURL: mentionedAccount.URL, GTSAccount: mentionedAccount, }) } @@ -1351,10 +1361,15 @@ func (ps *postgresService) TagStringsToTags(tags []string, originAccountID strin tag := &gtsmodel.Tag{} // we can use selectorinsert here to create the new tag if it doesn't exist already // inserted will be true if this is a new tag we just created - if err := ps.conn.Model(tag).Where("name = ?", t).Select(); err != nil { + if err := ps.conn.Model(tag).Where("LOWER(?) = LOWER(?)", pg.Ident("name"), t).Select(); err != nil { if err == pg.ErrNoRows { // tag doesn't exist yet so populate it - tag.ID = uuid.NewString() + newID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + tag.ID = newID + tag.URL = fmt.Sprintf("%s://%s/tags/%s", ps.config.Protocol, ps.config.Host, t) tag.Name = t tag.FirstSeenFromAccountID = originAccountID tag.CreatedAt = time.Now() diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go @@ -9,6 +9,7 @@ import ( "github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams/vocab" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -58,7 +59,43 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA } for iter := acceptObject.Begin(); iter != acceptObject.End(); iter = iter.Next() { + // check if the object is an IRI + if iter.IsIRI() { + // we have just the URI of whatever is being accepted, so we need to find out what it is + acceptedObjectIRI := iter.GetIRI() + if util.IsFollowPath(acceptedObjectIRI) { + // ACCEPT FOLLOW + gtsFollowRequest := &gtsmodel.FollowRequest{} + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: acceptedObjectIRI.String()}}, gtsFollowRequest); err != nil { + return fmt.Errorf("ACCEPT: couldn't get follow request with id %s from the database: %s", acceptedObjectIRI.String(), err) + } + + // make sure the addressee of the original follow is the same as whatever inbox this landed in + if gtsFollowRequest.AccountID != inboxAcct.ID { + return errors.New("ACCEPT: follow object account and inbox account were not the same") + } + follow, err := f.db.AcceptFollowRequest(gtsFollowRequest.AccountID, gtsFollowRequest.TargetAccountID) + if err != nil { + return err + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsAccept, + GTSModel: follow, + ReceivingAccount: inboxAcct, + } + + return nil + } + } + + // check if iter is an AP object / type + if iter.GetType() == nil { + continue + } switch iter.GetType().GetTypeName() { + // we have the whole object so we can figure out what we're accepting case string(gtsmodel.ActivityStreamsFollow): // ACCEPT FOLLOW asFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go @@ -29,6 +29,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -99,10 +100,21 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { if err != nil { return fmt.Errorf("error converting note to status: %s", err) } + + // id the status based on the time it was created + statusID, err := id.NewULIDFromTime(status.CreatedAt) + if err != nil { + return err + } + status.ID = statusID + if err := f.db.Put(status); err != nil { if _, ok := err.(db.ErrAlreadyExists); ok { + // the status already exists in the database, which means we've already handled everything else, + // so we can just return nil here and be done with it. return nil } + // an actual error has happened return fmt.Errorf("database error inserting status: %s", err) } @@ -125,6 +137,12 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { return fmt.Errorf("could not convert Follow to follow request: %s", err) } + newID, err := id.NewULID() + if err != nil { + return err + } + followRequest.ID = newID + if err := f.db.Put(followRequest); err != nil { return fmt.Errorf("database error inserting follow request: %s", err) } @@ -146,6 +164,12 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { return fmt.Errorf("could not convert Like to fave: %s", err) } + newID, err := id.NewULID() + if err != nil { + return err + } + fave.ID = newID + if err := f.db.Put(fave); err != nil { return fmt.Errorf("database error inserting fave: %s", err) } diff --git a/internal/federation/federatingdb/followers.go b/internal/federation/federatingdb/followers.go @@ -43,7 +43,7 @@ func (f *federatingDB) Followers(c context.Context, actorIRI *url.URL) (follower } acctFollowers := []gtsmodel.Follow{} - if err := f.db.GetFollowersByAccountID(acct.ID, &acctFollowers); err != nil { + if err := f.db.GetFollowersByAccountID(acct.ID, &acctFollowers, false); err != nil { return nil, fmt.Errorf("db error getting followers for account id %s: %s", acct.ID, err) } diff --git a/internal/federation/federatingdb/undo.go b/internal/federation/federatingdb/undo.go @@ -48,6 +48,9 @@ func (f *federatingDB) Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) } for iter := undoObject.Begin(); iter != undoObject.End(); iter = iter.Next() { + if iter.GetType() == nil { + continue + } switch iter.GetType().GetTypeName() { case string(gtsmodel.ActivityStreamsFollow): // UNDO FOLLOW diff --git a/internal/federation/federatingdb/util.go b/internal/federation/federatingdb/util.go @@ -27,10 +27,10 @@ import ( "github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams/vocab" - "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -60,7 +60,7 @@ func sameActor(activityActor vocab.ActivityStreamsActorProperty, followActor voc // // The go-fed library will handle setting the 'id' property on the // activity or object provided with the value returned. -func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err error) { +func (f *federatingDB) NewID(c context.Context, t vocab.Type) (idURL *url.URL, err error) { l := f.log.WithFields( logrus.Fields{ "func": "NewID", @@ -99,7 +99,11 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err if iter.IsIRI() { actorAccount := &gtsmodel.Account{} if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: iter.GetIRI().String()}}, actorAccount); err == nil { // if there's an error here, just use the fallback behavior -- we don't need to return an error here - return url.Parse(util.GenerateURIForFollow(actorAccount.Username, f.config.Protocol, f.config.Host, uuid.NewString())) + newID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + return url.Parse(util.GenerateURIForFollow(actorAccount.Username, f.config.Protocol, f.config.Host, newID)) } } } @@ -158,8 +162,12 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err } } - // fallback default behavior: just return a random UUID after our protocol and host - return url.Parse(fmt.Sprintf("%s://%s/%s", f.config.Protocol, f.config.Host, uuid.NewString())) + // fallback default behavior: just return a random ULID after our protocol and host + newID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + return url.Parse(fmt.Sprintf("%s://%s/%s", f.config.Protocol, f.config.Host, newID)) } // ActorForOutbox fetches the actor's IRI for the given outbox IRI. diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go @@ -31,6 +31,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -142,6 +143,12 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err) } + aID, err := id.NewRandomULID() + if err != nil { + return ctx, false, err + } + a.ID = aID + if err := f.db.Put(a); err != nil { l.Errorf("error inserting dereferenced remote account: %s", err) } diff --git a/internal/gtserror/withcode.go b/internal/gtserror/withcode.go @@ -0,0 +1,124 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtserror + +import ( + "errors" + "net/http" + "strings" +) + +// WithCode wraps an internal error with an http code, and a 'safe' version of +// the error that can be served to clients without revealing internal business logic. +// +// A typical use of this error would be to first log the Original error, then return +// the Safe error and the StatusCode to an API caller. +type WithCode interface { + // Error returns the original internal error for debugging within the GoToSocial logs. + // This should *NEVER* be returned to a client as it may contain sensitive information. + Error() string + // Safe returns the API-safe version of the error for serialization towards a client. + // There's not much point logging this internally because it won't contain much helpful information. + Safe() string + // Code returns the status code for serving to a client. + Code() int +} + +type withCode struct { + original error + safe error + code int +} + +func (e withCode) Error() string { + return e.original.Error() +} + +func (e withCode) Safe() string { + return e.safe.Error() +} + +func (e withCode) Code() int { + return e.code +} + +// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text. +func NewErrorBadRequest(original error, helpText ...string) WithCode { + safe := "bad request" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return withCode{ + original: original, + safe: errors.New(safe), + code: http.StatusBadRequest, + } +} + +// NewErrorNotAuthorized returns an ErrorWithCode 401 with the given original error and optional help text. +func NewErrorNotAuthorized(original error, helpText ...string) WithCode { + safe := "not authorized" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return withCode{ + original: original, + safe: errors.New(safe), + code: http.StatusUnauthorized, + } +} + +// NewErrorForbidden returns an ErrorWithCode 403 with the given original error and optional help text. +func NewErrorForbidden(original error, helpText ...string) WithCode { + safe := "forbidden" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return withCode{ + original: original, + safe: errors.New(safe), + code: http.StatusForbidden, + } +} + +// NewErrorNotFound returns an ErrorWithCode 404 with the given original error and optional help text. +func NewErrorNotFound(original error, helpText ...string) WithCode { + safe := "404 not found" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return withCode{ + original: original, + safe: errors.New(safe), + code: http.StatusNotFound, + } +} + +// NewErrorInternalError returns an ErrorWithCode 500 with the given original error and optional help text. +func NewErrorInternalError(original error, helpText ...string) WithCode { + safe := "internal server error" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return withCode{ + original: original, + safe: errors.New(safe), + code: http.StatusInternalServerError, + } +} diff --git a/internal/gtsmodel/README.md b/internal/gtsmodel/README.md @@ -1,5 +0,0 @@ -# gtsmodel - -This package contains types used *internally* by GoToSocial and added/removed/selected from the database. As such, they contain sensitive fields which should **never** be serialized or reach the API level. Use the [mastotypes](../../pkg/mastotypes) package for that. - -The annotation used on these structs is for handling them via the go-pg ORM. See [here](https://pg.uptrace.dev/models/). diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go @@ -33,8 +33,8 @@ type Account struct { BASIC INFO */ - // id of this account in the local database; the end-user will never need to know this, it's strictly internal - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // id of this account in the local database + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org`` Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other // Domain of the account, will be null if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. @@ -45,11 +45,11 @@ type Account struct { */ // ID of the avatar as a media attachment - AvatarMediaAttachmentID string + AvatarMediaAttachmentID string `pg:"type:CHAR(26)"` // For a non-local account, where can the header be fetched? AvatarRemoteURL string // ID of the header as a media attachment - HeaderMediaAttachmentID string + HeaderMediaAttachmentID string `pg:"type:CHAR(26)"` // For a non-local account, where can the header be fetched? HeaderRemoteURL string // DisplayName for this account. Can be empty, then just the Username will be used for display purposes. @@ -61,7 +61,7 @@ type Account struct { // Is this a memorial account, ie., has the user passed away? Memorial bool // This account has moved this account id in the database - MovedToAccountID string + MovedToAccountID string `pg:"type:CHAR(26)"` // When was this account created? CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // When was this account last updated? diff --git a/internal/gtsmodel/application.go b/internal/gtsmodel/application.go @@ -22,7 +22,7 @@ package gtsmodel // It is used to authorize tokens etc, and is associated with an oauth client id in the database. type Application struct { // id of this application in the db - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + ID string `pg:"type:CHAR(26),pk,notnull"` // name of the application given when it was created (eg., 'tusky') Name string // website for the application given when it was created (eg., 'https://tusky.app') @@ -30,7 +30,7 @@ type Application struct { // redirect uri requested by the application for oauth2 flow RedirectURI string // id of the associated oauth client entity in the db - ClientID string + ClientID string `pg:"type:CHAR(26)"` // secret of the associated oauth client entity in the db ClientSecret string // scopes requested when this app was created diff --git a/internal/gtsmodel/block.go b/internal/gtsmodel/block.go @@ -5,15 +5,15 @@ import "time" // Block refers to the blocking of one account by another. type Block struct { // id of this block in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + ID string `pg:"type:CHAR(26),pk,notnull"` // When was this block created CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // When was this block updated UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // Who created this block? - AccountID string `pg:",notnull"` + AccountID string `pg:"type:CHAR(26),notnull"` // Who is targeted by this block? - TargetAccountID string `pg:",notnull"` + TargetAccountID string `pg:"type:CHAR(26),notnull"` // Activitypub URI for this block URI string } diff --git a/internal/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go @@ -23,7 +23,7 @@ import "time" // DomainBlock represents a federation block against a particular domain, of varying severity. type DomainBlock struct { // ID of this block in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // Domain to block. If ANY PART of the candidate domain contains this string, it will be blocked. // For example: 'example.org' also blocks 'gts.example.org'. '.com' blocks *any* '.com' domains. // TODO: implement wildcards here @@ -33,7 +33,7 @@ type DomainBlock struct { // When was this block updated UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // Account ID of the creator of this block - CreatedByAccountID string `pg:",notnull"` + CreatedByAccountID string `pg:"type:CHAR(26),notnull"` // TODO: define this Severity int // Reject media from this domain? diff --git a/internal/gtsmodel/emaildomainblock.go b/internal/gtsmodel/emaildomainblock.go @@ -23,7 +23,7 @@ import "time" // EmailDomainBlock represents a domain that the server should automatically reject sign-up requests from. type EmailDomainBlock struct { // ID of this block in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // Email domain to block. Eg. 'gmail.com' or 'hotmail.com' Domain string `pg:",notnull"` // When was this block created @@ -31,5 +31,5 @@ type EmailDomainBlock struct { // When was this block updated UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // Account ID of the creator of this block - CreatedByAccountID string `pg:",notnull"` + CreatedByAccountID string `pg:"type:CHAR(26),notnull"` } diff --git a/internal/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go @@ -23,7 +23,7 @@ import "time" // Emoji represents a custom emoji that's been uploaded through the admin UI, and is useable by instance denizens. type Emoji struct { // database ID of this emoji - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + ID string `pg:"type:CHAR(26),pk,notnull"` // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_ // eg., 'blob_hug' 'purple_heart' Must be unique with domain. Shortcode string `pg:",notnull,unique:shortcodedomain"` @@ -73,5 +73,5 @@ type Emoji struct { // Is this emoji visible in the admin emoji picker? VisibleInPicker bool `pg:",notnull,default:true"` // In which emoji category is this emoji visible? - CategoryID string + CategoryID string `pg:"type:CHAR(26)"` } diff --git a/internal/gtsmodel/follow.go b/internal/gtsmodel/follow.go @@ -23,15 +23,15 @@ import "time" // Follow represents one account following another, and the metadata around that follow. type Follow struct { // id of this follow in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // When was this follow created? CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // When was this follow last updated? UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // Who does this follow belong to? - AccountID string `pg:",unique:srctarget,notnull"` + AccountID string `pg:"type:CHAR(26),unique:srctarget,notnull"` // Who does AccountID follow? - TargetAccountID string `pg:",unique:srctarget,notnull"` + TargetAccountID string `pg:"type:CHAR(26),unique:srctarget,notnull"` // Does this follow also want to see reblogs and not just posts? ShowReblogs bool `pg:"default:true"` // What is the activitypub URI of this follow? diff --git a/internal/gtsmodel/followrequest.go b/internal/gtsmodel/followrequest.go @@ -23,15 +23,15 @@ import "time" // FollowRequest represents one account requesting to follow another, and the metadata around that request. type FollowRequest struct { // id of this follow request in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // When was this follow request created? CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // When was this follow request last updated? UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // Who does this follow request originate from? - AccountID string `pg:",unique:srctarget,notnull"` + AccountID string `pg:"type:CHAR(26),unique:srctarget,notnull"` // Who is the target of this follow request? - TargetAccountID string `pg:",unique:srctarget,notnull"` + TargetAccountID string `pg:"type:CHAR(26),unique:srctarget,notnull"` // Does this follow also want to see reblogs and not just posts? ShowReblogs bool `pg:"default:true"` // What is the activitypub URI of this follow request? diff --git a/internal/gtsmodel/instance.go b/internal/gtsmodel/instance.go @@ -5,7 +5,7 @@ import "time" // Instance represents a federated instance, either local or remote. type Instance struct { // ID of this instance in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // Instance domain eg example.org Domain string `pg:",notnull,unique"` // Title of this instance as it would like to be displayed. @@ -19,7 +19,7 @@ type Instance struct { // When was this instance suspended, if at all? SuspendedAt time.Time // ID of any existing domain block for this instance in the database - DomainBlockID string + DomainBlockID string `pg:"type:CHAR(26)"` // Short description of this instance ShortDescription string // Longer description of this instance @@ -27,7 +27,7 @@ type Instance struct { // Contact email address for this instance ContactEmail string // Contact account ID in the database for this instance - ContactAccountID string + ContactAccountID string `pg:"type:CHAR(26)"` // Reputation score of this instance Reputation int64 `pg:",notnull,default:0"` // Version of the software used on this instance diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go @@ -26,9 +26,9 @@ import ( // somewhere in storage and that can be retrieved and served by the router. type MediaAttachment struct { // ID of the attachment in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // ID of the status to which this is attached - StatusID string + StatusID string `pg:"type:CHAR(26)"` // Where can the attachment be retrieved on *this* server URL string // Where can the attachment be retrieved on a remote server (empty for local media) @@ -42,11 +42,11 @@ type MediaAttachment struct { // Metadata about the file FileMeta FileMeta // To which account does this attachment belong - AccountID string `pg:",notnull"` + AccountID string `pg:"type:CHAR(26),notnull"` // Description of the attachment (for screenreaders) Description string // To which scheduled status does this attachment belong - ScheduledStatusID string + ScheduledStatusID string `pg:"type:CHAR(26)"` // What is the generated blurhash of this attachment Blurhash string // What is the processing status of this attachment diff --git a/internal/gtsmodel/mention.go b/internal/gtsmodel/mention.go @@ -23,19 +23,19 @@ import "time" // Mention refers to the 'tagging' or 'mention' of a user within a status. type Mention struct { // ID of this mention in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // ID of the status this mention originates from - StatusID string `pg:",notnull"` + StatusID string `pg:"type:CHAR(26),notnull"` // When was this mention created? CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // When was this mention last updated? UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // What's the internal account ID of the originator of the mention? - OriginAccountID string `pg:",notnull"` + OriginAccountID string `pg:"type:CHAR(26),notnull"` // What's the AP URI of the originator of the mention? OriginAccountURI string `pg:",notnull"` // What's the internal account ID of the mention target? - TargetAccountID string `pg:",notnull"` + TargetAccountID string `pg:"type:CHAR(26),notnull"` // Prevent this mention from generating a notification? Silent bool @@ -56,6 +56,10 @@ type Mention struct { // // This will not be put in the database, it's just for convenience. MentionedAccountURI string `pg:"-"` + // MentionedAccountURL is the web url of the user mentioned. + // + // This will not be put in the database, it's just for convenience. + MentionedAccountURL string `pg:"-"` // A pointer to the gtsmodel account of the mentioned account. GTSAccount *Account `pg:"-"` } diff --git a/internal/gtsmodel/notification.go b/internal/gtsmodel/notification.go @@ -23,17 +23,17 @@ import "time" // Notification models an alert/notification sent to an account about something like a reblog, like, new follow request, etc. type Notification struct { // ID of this notification in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + ID string `pg:"type:CHAR(26),pk,notnull"` // Type of this notification NotificationType NotificationType `pg:",notnull"` // Creation time of this notification CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // Which account does this notification target (ie., who will receive the notification?) - TargetAccountID string `pg:",notnull"` + TargetAccountID string `pg:"type:CHAR(26),notnull"` // Which account performed the action that created this notification? - OriginAccountID string `pg:",notnull"` + OriginAccountID string `pg:"type:CHAR(26),notnull"` // If the notification pertains to a status, what is the database ID of that status? - StatusID string + StatusID string `pg:"type:CHAR(26)"` // Has this notification been read already? Read bool diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go @@ -23,7 +23,7 @@ import "time" // Status represents a user-created 'post' or 'status' in the database, either remote or local type Status struct { // id of the status in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + ID string `pg:"type:CHAR(26),pk,notnull"` // uri at which this status is reachable URI string `pg:",unique"` // web url for viewing this status @@ -45,13 +45,13 @@ type Status struct { // is this status from a local account? Local bool // which account posted this status? - AccountID string + AccountID string `pg:"type:CHAR(26),notnull"` // id of the status this status is a reply to - InReplyToID string + InReplyToID string `pg:"type:CHAR(26)"` // id of the account that this status replies to - InReplyToAccountID string + InReplyToAccountID string `pg:"type:CHAR(26)"` // id of the status this status is a boost of - BoostOfID string + BoostOfID string `pg:"type:CHAR(26)"` // cw string for this status ContentWarning string // visibility entry for this status @@ -61,7 +61,7 @@ type Status struct { // what language is this status written in? Language string // Which application was used to create this status? - CreatedWithApplicationID string + CreatedWithApplicationID string `pg:"type:CHAR(26)"` // advanced visibility for this status VisibilityAdvanced *VisibilityAdvanced // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types @@ -153,6 +153,7 @@ type VisibilityAdvanced struct { // RelevantAccounts denotes accounts that are replied to, boosted by, or mentioned in a status. type RelevantAccounts struct { + StatusAuthor *Account ReplyToAccount *Account BoostedAccount *Account BoostedReplyToAccount *Account diff --git a/internal/gtsmodel/statusbookmark.go b/internal/gtsmodel/statusbookmark.go @@ -23,13 +23,13 @@ import "time" // StatusBookmark refers to one account having a 'bookmark' of the status of another account type StatusBookmark struct { // id of this bookmark in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // when was this bookmark created CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // id of the account that created ('did') the bookmarking - AccountID string `pg:",notnull"` + AccountID string `pg:"type:CHAR(26),notnull"` // id the account owning the bookmarked status - TargetAccountID string `pg:",notnull"` + TargetAccountID string `pg:"type:CHAR(26),notnull"` // database id of the status that has been bookmarked - StatusID string `pg:",notnull"` + StatusID string `pg:"type:CHAR(26),notnull"` } diff --git a/internal/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go @@ -23,15 +23,15 @@ import "time" // StatusFave refers to a 'fave' or 'like' in the database, from one account, targeting the status of another account type StatusFave struct { // id of this fave in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // when was this fave created CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // id of the account that created ('did') the fave - AccountID string `pg:",notnull"` + AccountID string `pg:"type:CHAR(26),notnull"` // id the account owning the faved status - TargetAccountID string `pg:",notnull"` + TargetAccountID string `pg:"type:CHAR(26),notnull"` // database id of the status that has been 'faved' - StatusID string `pg:",notnull"` + StatusID string `pg:"type:CHAR(26),notnull"` // ActivityPub URI of this fave URI string `pg:",notnull"` diff --git a/internal/gtsmodel/statusmute.go b/internal/gtsmodel/statusmute.go @@ -23,13 +23,13 @@ import "time" // StatusMute refers to one account having muted the status of another account or its own type StatusMute struct { // id of this mute in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // when was this mute created CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // id of the account that created ('did') the mute - AccountID string `pg:",notnull"` + AccountID string `pg:"type:CHAR(26),notnull"` // id the account owning the muted status (can be the same as accountID) - TargetAccountID string `pg:",notnull"` + TargetAccountID string `pg:"type:CHAR(26),notnull"` // database id of the status that has been muted - StatusID string `pg:",notnull"` + StatusID string `pg:"type:CHAR(26),notnull"` } diff --git a/internal/gtsmodel/tag.go b/internal/gtsmodel/tag.go @@ -23,13 +23,13 @@ import "time" // Tag represents a hashtag for gathering public statuses together type Tag struct { // id of this tag in the database - ID string `pg:",unique,type:uuid,default:gen_random_uuid(),pk,notnull"` + ID string `pg:",unique,type:CHAR(26),pk,notnull"` // Href of this tag, eg https://example.org/tags/somehashtag URL string // name of this tag -- the tag without the hash part Name string `pg:",unique,pk,notnull"` // Which account ID is the first one we saw using this tag? - FirstSeenFromAccountID string + FirstSeenFromAccountID string `pg:"type:CHAR(26)"` // when was this tag created CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // when was this tag last updated diff --git a/internal/gtsmodel/user.go b/internal/gtsmodel/user.go @@ -31,11 +31,11 @@ type User struct { */ // id of this user in the local database; the end-user will never need to know this, it's strictly internal - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + ID string `pg:"type:CHAR(26),pk,notnull,unique"` // confirmed email address for this user, this should be unique -- only one email address registered per instance, multiple users per email are not supported Email string `pg:"default:null,unique"` // The id of the local gtsmodel.Account entry for this user, if it exists (unconfirmed users don't have an account yet) - AccountID string `pg:"default:'',notnull,unique"` + AccountID string `pg:"type:CHAR(26),unique"` // The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables EncryptedPassword string `pg:",notnull"` @@ -60,7 +60,7 @@ type User struct { // How many times has this user signed in? SignInCount int // id of the user who invited this user (who let this guy in?) - InviteID string + InviteID string `pg:"type:CHAR(26)"` // What languages does this user want to see? ChosenLanguages []string // What languages does this user not want to see? @@ -68,7 +68,7 @@ type User struct { // In what timezone/locale is this user located? Locale string // Which application id created this user? See gtsmodel.Application - CreatedByApplicationID string + CreatedByApplicationID string `pg:"type:CHAR(26)"` // When did we last contact this user LastEmailedAt time.Time `pg:"type:timestamp"` diff --git a/internal/id/ulid.go b/internal/id/ulid.go @@ -0,0 +1,51 @@ +package id + +import ( + "crypto/rand" + "math/big" + "time" + + "github.com/oklog/ulid" +) + +const randomRange = 631152381 // ~20 years in seconds + +// NewULID returns a new ULID string using the current time, or an error if something goes wrong. +func NewULID() (string, error) { + newUlid, err := ulid.New(ulid.Timestamp(time.Now()), rand.Reader) + if err != nil { + return "", err + } + return newUlid.String(), nil +} + +// NewULIDFromTime returns a new ULID string using the given time, or an error if something goes wrong. +func NewULIDFromTime(t time.Time) (string, error) { + newUlid, err := ulid.New(ulid.Timestamp(t), rand.Reader) + if err != nil { + return "", err + } + return newUlid.String(), nil +} + +// NewRandomULID returns a new ULID string using a random time in an ~80 year range around the current datetime, or an error if something goes wrong. +func NewRandomULID() (string, error) { + b1, err := rand.Int(rand.Reader, big.NewInt(randomRange)) + if err != nil { + return "", err + } + r1 := time.Duration(int(b1.Int64())) + + b2, err := rand.Int(rand.Reader, big.NewInt(randomRange)) + if err != nil { + return "", err + } + r2 := -time.Duration(int(b2.Int64())) + + arbitraryTime := time.Now().Add(r1 * time.Second).Add(r2 * time.Second) + newUlid, err := ulid.New(ulid.Timestamp(arbitraryTime), rand.Reader) + if err != nil { + return "", err + } + return newUlid.String(), nil +} diff --git a/internal/media/handler.go b/internal/media/handler.go @@ -26,12 +26,12 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/blob" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/transport" ) @@ -242,9 +242,11 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) ( // create the urls and storage paths URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) - // generate a uuid for the new emoji -- normally we could let the database do this for us, - // but we need it below so we should create it here instead. - newEmojiID := uuid.NewString() + // generate a id for the new emoji + newEmojiID, err := id.NewRandomULID() + if err != nil { + return nil, err + } // webfinger uri for the emoji -- unrelated to actually serving the image // will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c diff --git a/internal/media/processicon.go b/internal/media/processicon.go @@ -24,8 +24,8 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" ) func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string, remoteURL string) (*gtsmodel.MediaAttachment, error) { @@ -72,9 +72,12 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string return nil, fmt.Errorf("error deriving thumbnail: %s", err) } - // now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it + // now put it in storage, take a new id for the name of the file so we don't store any unnecessary info about it extension := strings.Split(contentType, "/")[1] - newMediaID := uuid.NewString() + newMediaID, err := id.NewRandomULID() + if err != nil { + return nil, err + } URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, mediaType, newMediaID, extension) diff --git a/internal/media/processimage.go b/internal/media/processimage.go @@ -24,8 +24,8 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" ) func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, contentType string, remoteURL string) (*gtsmodel.MediaAttachment, error) { @@ -58,9 +58,12 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co return nil, fmt.Errorf("error deriving thumbnail: %s", err) } - // now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it + // now put it in storage, take a new id for the name of the file so we don't store any unnecessary info about it extension := strings.Split(contentType, "/")[1] - newMediaID := uuid.NewString() + newMediaID, err := id.NewRandomULID() + if err != nil { + return nil, err + } URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) originalURL := fmt.Sprintf("%s/%s/attachment/original/%s.%s", URLbase, accountID, newMediaID, extension) diff --git a/internal/oauth/clientstore.go b/internal/oauth/clientstore.go @@ -67,7 +67,7 @@ func (cs *clientStore) Delete(ctx context.Context, id string) error { // Client is a handy little wrapper for typical oauth client details type Client struct { - ID string + ID string `pg:"type:CHAR(26),pk,notnull"` Secret string Domain string UserID string diff --git a/internal/oauth/tokenstore.go b/internal/oauth/tokenstore.go @@ -26,6 +26,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/oauth2/v4" "github.com/superseriousbusiness/oauth2/v4/models" ) @@ -98,7 +99,17 @@ func (pts *tokenStore) Create(ctx context.Context, info oauth2.TokenInfo) error if !ok { return errors.New("info param was not a models.Token") } - if err := pts.db.Put(TokenToPGToken(t)); err != nil { + + pgt := TokenToPGToken(t) + if pgt.ID == "" { + pgtID, err := id.NewRandomULID() + if err != nil { + return err + } + pgt.ID = pgtID + } + + if err := pts.db.Put(pgt); err != nil { return fmt.Errorf("error in tokenstore create: %s", err) } return nil @@ -176,7 +187,7 @@ func (pts *tokenStore) GetByRefresh(ctx context.Context, refresh string) (oauth2 // As such, manual translation is always required between Token and the gotosocial *model.Token. The helper functions oauthTokenToPGToken // and pgTokenToOauthToken can be used for that. type Token struct { - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + ID string `pg:"type:CHAR(26),pk,notnull"` ClientID string UserID string RedirectURI string diff --git a/internal/processing/account.go b/internal/processing/account.go @@ -22,10 +22,11 @@ import ( "errors" "fmt" - "github.com/google/uuid" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -202,13 +203,13 @@ func (p *processor) AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCrede return acctSensitive, nil } -func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, ErrorWithCode) { +func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) { targetAccount := &gtsmodel.Account{} if err := p.db.GetByID(targetAccountID, targetAccount); err != nil { if _, ok := err.(db.ErrNoEntries); ok { - return nil, NewErrorNotFound(fmt.Errorf("no entry found for account id %s", targetAccountID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("no entry found for account id %s", targetAccountID)) } - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } statuses := []gtsmodel.Status{} @@ -217,18 +218,18 @@ func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID strin if _, ok := err.(db.ErrNoEntries); ok { return apiStatuses, nil } - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } for _, s := range statuses { relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(&s) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error getting relevant statuses: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error getting relevant statuses: %s", err)) } - visible, err := p.db.StatusVisible(&s, targetAccount, authed.Account, relevantAccounts) + visible, err := p.db.StatusVisible(&s, authed.Account, relevantAccounts) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error checking status visibility: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking status visibility: %s", err)) } if !visible { continue @@ -238,16 +239,16 @@ func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID strin if s.BoostOfID != "" { bs := &gtsmodel.Status{} if err := p.db.GetByID(s.BoostOfID, bs); err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error getting boosted status: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error getting boosted status: %s", err)) } boostedRelevantAccounts, err := p.db.PullRelevantAccountsFromStatus(bs) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error getting relevant accounts from boosted status: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error getting relevant accounts from boosted status: %s", err)) } - boostedVisible, err := p.db.StatusVisible(bs, relevantAccounts.BoostedAccount, authed.Account, boostedRelevantAccounts) + boostedVisible, err := p.db.StatusVisible(bs, authed.Account, boostedRelevantAccounts) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error checking boosted status visibility: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking boosted status visibility: %s", err)) } if boostedVisible { @@ -257,7 +258,7 @@ func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID strin apiStatus, err := p.tc.StatusToMasto(&s, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostedStatus) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error converting status to masto: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to masto: %s", err)) } apiStatuses = append(apiStatuses, *apiStatus) @@ -266,29 +267,29 @@ func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID strin return apiStatuses, nil } -func (p *processor) AccountFollowersGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, ErrorWithCode) { +func (p *processor) AccountFollowersGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { blocked, err := p.db.Blocked(authed.Account.ID, targetAccountID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, NewErrorNotFound(fmt.Errorf("block exists between accounts")) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) } followers := []gtsmodel.Follow{} accounts := []apimodel.Account{} - if err := p.db.GetFollowersByAccountID(targetAccountID, &followers); err != nil { + if err := p.db.GetFollowersByAccountID(targetAccountID, &followers, false); err != nil { if _, ok := err.(db.ErrNoEntries); ok { return accounts, nil } - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } for _, f := range followers { blocked, err := p.db.Blocked(authed.Account.ID, f.AccountID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { continue @@ -299,7 +300,7 @@ func (p *processor) AccountFollowersGet(authed *oauth.Auth, targetAccountID stri if _, ok := err.(db.ErrNoEntries); ok { continue } - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } // derefence account fields in case we haven't done it already @@ -310,21 +311,21 @@ func (p *processor) AccountFollowersGet(authed *oauth.Auth, targetAccountID stri account, err := p.tc.AccountToMastoPublic(a) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } accounts = append(accounts, *account) } return accounts, nil } -func (p *processor) AccountFollowingGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, ErrorWithCode) { +func (p *processor) AccountFollowingGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { blocked, err := p.db.Blocked(authed.Account.ID, targetAccountID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, NewErrorNotFound(fmt.Errorf("block exists between accounts")) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) } following := []gtsmodel.Follow{} @@ -333,13 +334,13 @@ func (p *processor) AccountFollowingGet(authed *oauth.Auth, targetAccountID stri if _, ok := err.(db.ErrNoEntries); ok { return accounts, nil } - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } for _, f := range following { blocked, err := p.db.Blocked(authed.Account.ID, f.AccountID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { continue @@ -350,7 +351,7 @@ func (p *processor) AccountFollowingGet(authed *oauth.Auth, targetAccountID stri if _, ok := err.(db.ErrNoEntries); ok { continue } - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } // derefence account fields in case we haven't done it already @@ -361,53 +362,53 @@ func (p *processor) AccountFollowingGet(authed *oauth.Auth, targetAccountID stri account, err := p.tc.AccountToMastoPublic(a) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } accounts = append(accounts, *account) } return accounts, nil } -func (p *processor) AccountRelationshipGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, ErrorWithCode) { +func (p *processor) AccountRelationshipGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { if authed == nil || authed.Account == nil { - return nil, NewErrorForbidden(errors.New("not authed")) + return nil, gtserror.NewErrorForbidden(errors.New("not authed")) } gtsR, err := p.db.GetRelationship(authed.Account.ID, targetAccountID) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error getting relationship: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error getting relationship: %s", err)) } r, err := p.tc.RelationshipToMasto(gtsR) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error converting relationship: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting relationship: %s", err)) } return r, nil } -func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, ErrorWithCode) { +func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, gtserror.WithCode) { // if there's a block between the accounts we shouldn't create the request ofc blocked, err := p.db.Blocked(authed.Account.ID, form.TargetAccountID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, NewErrorNotFound(fmt.Errorf("accountfollowcreate: block exists between accounts")) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("accountfollowcreate: block exists between accounts")) } // make sure the target account actually exists in our db targetAcct := &gtsmodel.Account{} if err := p.db.GetByID(form.TargetAccountID, targetAcct); err != nil { if _, ok := err.(db.ErrNoEntries); ok { - return nil, NewErrorNotFound(fmt.Errorf("accountfollowcreate: account %s not found in the db: %s", form.TargetAccountID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("accountfollowcreate: account %s not found in the db: %s", form.TargetAccountID, err)) } } // check if a follow exists already follows, err := p.db.Follows(authed.Account, targetAcct) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow in db: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow in db: %s", err)) } if follows { // already follows so just return the relationship @@ -417,7 +418,7 @@ func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.Accou // check if a follow exists already followRequested, err := p.db.FollowRequested(authed.Account, targetAcct) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow request in db: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow request in db: %s", err)) } if followRequested { // already follow requested so just return the relationship @@ -425,8 +426,10 @@ func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.Accou } // make the follow request - - newFollowID := uuid.NewString() + newFollowID, err := id.NewRandomULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } fr := &gtsmodel.FollowRequest{ ID: newFollowID, @@ -445,13 +448,13 @@ func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.Accou // whack it in the database if err := p.db.Put(fr); err != nil { - return nil, NewErrorInternalError(fmt.Errorf("accountfollowcreate: error creating follow request in db: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error creating follow request in db: %s", err)) } // if it's a local account that's not locked we can just straight up accept the follow request if !targetAcct.Locked && targetAcct.Domain == "" { if _, err := p.db.AcceptFollowRequest(authed.Account.ID, form.TargetAccountID); err != nil { - return nil, NewErrorInternalError(fmt.Errorf("accountfollowcreate: error accepting folow request for local unlocked account: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error accepting folow request for local unlocked account: %s", err)) } // return the new relationship return p.AccountRelationshipGet(authed, form.TargetAccountID) @@ -470,21 +473,21 @@ func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.Accou return p.AccountRelationshipGet(authed, form.TargetAccountID) } -func (p *processor) AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, ErrorWithCode) { +func (p *processor) AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { // if there's a block between the accounts we shouldn't do anything blocked, err := p.db.Blocked(authed.Account.ID, targetAccountID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, NewErrorNotFound(fmt.Errorf("AccountFollowRemove: block exists between accounts")) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("AccountFollowRemove: block exists between accounts")) } // make sure the target account actually exists in our db targetAcct := &gtsmodel.Account{} if err := p.db.GetByID(targetAccountID, targetAcct); err != nil { if _, ok := err.(db.ErrNoEntries); ok { - return nil, NewErrorNotFound(fmt.Errorf("AccountFollowRemove: account %s not found in the db: %s", targetAccountID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("AccountFollowRemove: account %s not found in the db: %s", targetAccountID, err)) } } @@ -498,7 +501,7 @@ func (p *processor) AccountFollowRemove(authed *oauth.Auth, targetAccountID stri }, fr); err == nil { frURI = fr.URI if err := p.db.DeleteByID(fr.ID, fr); err != nil { - return nil, NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow request from db: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow request from db: %s", err)) } frChanged = true } @@ -513,7 +516,7 @@ func (p *processor) AccountFollowRemove(authed *oauth.Auth, targetAccountID stri }, f); err == nil { fURI = f.URI if err := p.db.DeleteByID(f.ID, f); err != nil { - return nil, NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow from db: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow from db: %s", err)) } fChanged = true } diff --git a/internal/processing/admin.go b/internal/processing/admin.go @@ -25,6 +25,7 @@ import ( "io" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -53,6 +54,12 @@ func (p *processor) AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCre return nil, fmt.Errorf("error reading emoji: %s", err) } + emojiID, err := id.NewULID() + if err != nil { + return nil, err + } + emoji.ID = emojiID + mastoEmoji, err := p.tc.EmojiToMasto(emoji) if err != nil { return nil, fmt.Errorf("error converting emoji to mastotype: %s", err) diff --git a/internal/processing/app.go b/internal/processing/app.go @@ -22,6 +22,7 @@ import ( "github.com/google/uuid" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -35,12 +36,21 @@ func (p *processor) AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCrea } // generate new IDs for this application and its associated client - clientID := uuid.NewString() + clientID, err := id.NewRandomULID() + if err != nil { + return nil, err + } clientSecret := uuid.NewString() vapidKey := uuid.NewString() + appID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + // generate the application to put in the database app := &gtsmodel.Application{ + ID: appID, Name: form.ClientName, Website: form.Website, RedirectURI: form.RedirectURIs, diff --git a/internal/processing/error.go b/internal/processing/error.go @@ -1,124 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package processing - -import ( - "errors" - "net/http" - "strings" -) - -// ErrorWithCode wraps an internal error with an http code, and a 'safe' version of -// the error that can be served to clients without revealing internal business logic. -// -// A typical use of this error would be to first log the Original error, then return -// the Safe error and the StatusCode to an API caller. -type ErrorWithCode interface { - // Error returns the original internal error for debugging within the GoToSocial logs. - // This should *NEVER* be returned to a client as it may contain sensitive information. - Error() string - // Safe returns the API-safe version of the error for serialization towards a client. - // There's not much point logging this internally because it won't contain much helpful information. - Safe() string - // Code returns the status code for serving to a client. - Code() int -} - -type errorWithCode struct { - original error - safe error - code int -} - -func (e errorWithCode) Error() string { - return e.original.Error() -} - -func (e errorWithCode) Safe() string { - return e.safe.Error() -} - -func (e errorWithCode) Code() int { - return e.code -} - -// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text. -func NewErrorBadRequest(original error, helpText ...string) ErrorWithCode { - safe := "bad request" - if helpText != nil { - safe = safe + ": " + strings.Join(helpText, ": ") - } - return errorWithCode{ - original: original, - safe: errors.New(safe), - code: http.StatusBadRequest, - } -} - -// NewErrorNotAuthorized returns an ErrorWithCode 401 with the given original error and optional help text. -func NewErrorNotAuthorized(original error, helpText ...string) ErrorWithCode { - safe := "not authorized" - if helpText != nil { - safe = safe + ": " + strings.Join(helpText, ": ") - } - return errorWithCode{ - original: original, - safe: errors.New(safe), - code: http.StatusUnauthorized, - } -} - -// NewErrorForbidden returns an ErrorWithCode 403 with the given original error and optional help text. -func NewErrorForbidden(original error, helpText ...string) ErrorWithCode { - safe := "forbidden" - if helpText != nil { - safe = safe + ": " + strings.Join(helpText, ": ") - } - return errorWithCode{ - original: original, - safe: errors.New(safe), - code: http.StatusForbidden, - } -} - -// NewErrorNotFound returns an ErrorWithCode 404 with the given original error and optional help text. -func NewErrorNotFound(original error, helpText ...string) ErrorWithCode { - safe := "404 not found" - if helpText != nil { - safe = safe + ": " + strings.Join(helpText, ": ") - } - return errorWithCode{ - original: original, - safe: errors.New(safe), - code: http.StatusNotFound, - } -} - -// NewErrorInternalError returns an ErrorWithCode 500 with the given original error and optional help text. -func NewErrorInternalError(original error, helpText ...string) ErrorWithCode { - safe := "internal server error" - if helpText != nil { - safe = safe + ": " + strings.Join(helpText, ": ") - } - return errorWithCode{ - original: original, - safe: errors.New(safe), - code: http.StatusInternalServerError, - } -} diff --git a/internal/processing/federation.go b/internal/processing/federation.go @@ -27,7 +27,9 @@ import ( "github.com/go-fed/activity/streams" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -73,13 +75,18 @@ func (p *processor) authenticateAndDereferenceFediRequest(username string, r *ht return nil, fmt.Errorf("couldn't convert dereferenced uri %s to gtsmodel account: %s", requestingAccountURI.String(), err) } - // shove it in the database for later + requestingAccountID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + requestingAccount.ID = requestingAccountID + if err := p.db.Put(requestingAccount); err != nil { return nil, fmt.Errorf("database error inserting account with uri %s: %s", requestingAccountURI.String(), err) } // put it in our channel to queue it for async processing - p.FromFederator() <- gtsmodel.FromFederator{ + p.fromFederator <- gtsmodel.FromFederator{ APObjectType: gtsmodel.ActivityStreamsProfile, APActivityType: gtsmodel.ActivityStreamsCreate, GTSModel: requestingAccount, @@ -88,141 +95,141 @@ func (p *processor) authenticateAndDereferenceFediRequest(username string, r *ht return requestingAccount, nil } -func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) { +func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) } // authenticate the request requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) if err != nil { - return nil, NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorNotAuthorized(err) } blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } requestedPerson, err := p.tc.AccountToAS(requestedAccount) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } data, err := streams.Serialize(requestedPerson) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } return data, nil } -func (p *processor) GetFediFollowers(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) { +func (p *processor) GetFediFollowers(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) } // authenticate the request requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) if err != nil { - return nil, NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorNotAuthorized(err) } blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } requestedAccountURI, err := url.Parse(requestedAccount.URI) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) } requestedFollowers, err := p.federator.FederatingDB().Followers(context.Background(), requestedAccountURI) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err)) } data, err := streams.Serialize(requestedFollowers) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } return data, nil } -func (p *processor) GetFediFollowing(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) { +func (p *processor) GetFediFollowing(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) } // authenticate the request requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) if err != nil { - return nil, NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorNotAuthorized(err) } blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } requestedAccountURI, err := url.Parse(requestedAccount.URI) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) } requestedFollowing, err := p.federator.FederatingDB().Following(context.Background(), requestedAccountURI) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err)) } data, err := streams.Serialize(requestedFollowing) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } return data, nil } -func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, ErrorWithCode) { +func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) } // authenticate the request requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) if err != nil { - return nil, NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorNotAuthorized(err) } blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } s := &gtsmodel.Status{} @@ -230,27 +237,27 @@ func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID st {Key: "id", Value: requestedStatusID}, {Key: "account_id", Value: requestedAccount.ID}, }, s); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err)) } asStatus, err := p.tc.StatusToAS(s) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } data, err := streams.Serialize(asStatus) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } return data, nil } -func (p *processor) GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode) { +func (p *processor) GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) } // return the webfinger representation diff --git a/internal/processing/followrequest.go b/internal/processing/followrequest.go @@ -21,15 +21,16 @@ package processing import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *processor) FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, ErrorWithCode) { +func (p *processor) FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, gtserror.WithCode) { frs := []gtsmodel.FollowRequest{} if err := p.db.GetFollowRequestsForAccountID(auth.Account.ID, &frs); err != nil { if _, ok := err.(db.ErrNoEntries); !ok { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } } @@ -37,31 +38,31 @@ func (p *processor) FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, Err for _, fr := range frs { acct := &gtsmodel.Account{} if err := p.db.GetByID(fr.AccountID, acct); err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } mastoAcct, err := p.tc.AccountToMastoPublic(acct) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } accts = append(accts, *mastoAcct) } return accts, nil } -func (p *processor) FollowRequestAccept(auth *oauth.Auth, accountID string) (*apimodel.Relationship, ErrorWithCode) { +func (p *processor) FollowRequestAccept(auth *oauth.Auth, accountID string) (*apimodel.Relationship, gtserror.WithCode) { follow, err := p.db.AcceptFollowRequest(accountID, auth.Account.ID) if err != nil { - return nil, NewErrorNotFound(err) + return nil, gtserror.NewErrorNotFound(err) } originAccount := &gtsmodel.Account{} if err := p.db.GetByID(follow.AccountID, originAccount); err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } targetAccount := &gtsmodel.Account{} if err := p.db.GetByID(follow.TargetAccountID, targetAccount); err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } p.fromClientAPI <- gtsmodel.FromClientAPI{ @@ -74,17 +75,17 @@ func (p *processor) FollowRequestAccept(auth *oauth.Auth, accountID string) (*ap gtsR, err := p.db.GetRelationship(auth.Account.ID, accountID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } r, err := p.tc.RelationshipToMasto(gtsR) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } return r, nil } -func (p *processor) FollowRequestDeny(auth *oauth.Auth) ErrorWithCode { +func (p *processor) FollowRequestDeny(auth *oauth.Auth) gtserror.WithCode { return nil } diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go @@ -40,6 +40,10 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error return errors.New("note was not parseable as *gtsmodel.Status") } + if err := p.timelineStatus(status); err != nil { + return err + } + if err := p.notifyStatus(status); err != nil { return err } @@ -47,7 +51,6 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error if status.VisibilityAdvanced != nil && status.VisibilityAdvanced.Federated { return p.federateStatus(status) } - return nil case gtsmodel.ActivityStreamsFollow: // CREATE FOLLOW REQUEST followRequest, ok := clientMsg.GTSModel.(*gtsmodel.FollowRequest) @@ -124,6 +127,29 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error return errors.New("undo was not parseable as *gtsmodel.Follow") } return p.federateUnfollow(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) + case gtsmodel.ActivityStreamsLike: + // UNDO LIKE/FAVE + fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return errors.New("undo was not parseable as *gtsmodel.StatusFave") + } + return p.federateUnfave(fave, clientMsg.OriginAccount, clientMsg.TargetAccount) + } + case gtsmodel.ActivityStreamsDelete: + // DELETE + switch clientMsg.APObjectType { + case gtsmodel.ActivityStreamsNote: + // DELETE STATUS/NOTE + statusToDelete, ok := clientMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("note was not parseable as *gtsmodel.Status") + } + + if err := p.deleteStatusFromTimelines(statusToDelete); err != nil { + return err + } + + return p.federateStatusDelete(statusToDelete, clientMsg.OriginAccount) } } return nil @@ -144,6 +170,43 @@ func (p *processor) federateStatus(status *gtsmodel.Status) error { return err } +func (p *processor) federateStatusDelete(status *gtsmodel.Status, originAccount *gtsmodel.Account) error { + asStatus, err := p.tc.StatusToAS(status) + if err != nil { + return fmt.Errorf("federateStatusDelete: error converting status to as format: %s", err) + } + + outboxIRI, err := url.Parse(originAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateStatusDelete: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) + } + + actorIRI, err := url.Parse(originAccount.URI) + if err != nil { + return fmt.Errorf("federateStatusDelete: error parsing actorIRI %s: %s", originAccount.URI, err) + } + + // create a delete and set the appropriate actor on it + delete := streams.NewActivityStreamsDelete() + + // set the actor for the delete + deleteActor := streams.NewActivityStreamsActorProperty() + deleteActor.AppendIRI(actorIRI) + delete.SetActivityStreamsActor(deleteActor) + + // Set the status as the 'object' property. + deleteObject := streams.NewActivityStreamsObjectProperty() + deleteObject.AppendActivityStreamsNote(asStatus) + delete.SetActivityStreamsObject(deleteObject) + + // set the to and cc as the original to/cc of the original status + delete.SetActivityStreamsTo(asStatus.GetActivityStreamsTo()) + delete.SetActivityStreamsCc(asStatus.GetActivityStreamsCc()) + + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, delete) + return err +} + func (p *processor) federateFollow(followRequest *gtsmodel.FollowRequest, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { // if both accounts are local there's nothing to do here if originAccount.Domain == "" && targetAccount.Domain == "" { @@ -207,6 +270,45 @@ func (p *processor) federateUnfollow(follow *gtsmodel.Follow, originAccount *gts return err } +func (p *processor) federateUnfave(fave *gtsmodel.StatusFave, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { + // if both accounts are local there's nothing to do here + if originAccount.Domain == "" && targetAccount.Domain == "" { + return nil + } + + // create the AS fave + asFave, err := p.tc.FaveToAS(fave) + if err != nil { + return fmt.Errorf("federateFave: error converting fave to as format: %s", err) + } + + targetAccountURI, err := url.Parse(targetAccount.URI) + if err != nil { + return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) + } + + // create an Undo and set the appropriate actor on it + undo := streams.NewActivityStreamsUndo() + undo.SetActivityStreamsActor(asFave.GetActivityStreamsActor()) + + // Set the fave as the 'object' property. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsLike(asFave) + undo.SetActivityStreamsObject(undoObject) + + // Set the To of the undo as the target of the fave + undoTo := streams.NewActivityStreamsToProperty() + undoTo.AppendIRI(targetAccountURI) + undo.SetActivityStreamsTo(undoTo) + + outboxIRI, err := url.Parse(originAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateFave: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) + } + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, undo) + return err +} + func (p *processor) federateAcceptFollowRequest(follow *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { // if both accounts are local there's nothing to do here if originAccount.Domain == "" && targetAccount.Domain == "" { diff --git a/internal/processing/fromcommon.go b/internal/processing/fromcommon.go @@ -20,9 +20,12 @@ package processing import ( "fmt" + "strings" + "sync" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" ) func (p *processor) notifyStatus(status *gtsmodel.Status) error { @@ -77,7 +80,13 @@ func (p *processor) notifyStatus(status *gtsmodel.Status) error { } // if we've reached this point we know the mention is for a local account, and the notification doesn't exist, so create it + notifID, err := id.NewULID() + if err != nil { + return err + } + notif := &gtsmodel.Notification{ + ID: notifID, NotificationType: gtsmodel.NotificationMention, TargetAccountID: m.TargetAccountID, OriginAccountID: status.AccountID, @@ -98,7 +107,13 @@ func (p *processor) notifyFollowRequest(followRequest *gtsmodel.FollowRequest, r return nil } + notifID, err := id.NewULID() + if err != nil { + return err + } + notif := &gtsmodel.Notification{ + ID: notifID, NotificationType: gtsmodel.NotificationFollowRequest, TargetAccountID: followRequest.TargetAccountID, OriginAccountID: followRequest.AccountID, @@ -127,7 +142,13 @@ func (p *processor) notifyFollow(follow *gtsmodel.Follow, receivingAccount *gtsm } // now create the new follow notification + notifID, err := id.NewULID() + if err != nil { + return err + } + notif := &gtsmodel.Notification{ + ID: notifID, NotificationType: gtsmodel.NotificationFollow, TargetAccountID: follow.TargetAccountID, OriginAccountID: follow.AccountID, @@ -145,7 +166,13 @@ func (p *processor) notifyFave(fave *gtsmodel.StatusFave, receivingAccount *gtsm return nil } + notifID, err := id.NewULID() + if err != nil { + return err + } + notif := &gtsmodel.Notification{ + ID: notifID, NotificationType: gtsmodel.NotificationFave, TargetAccountID: fave.TargetAccountID, OriginAccountID: fave.AccountID, @@ -198,7 +225,13 @@ func (p *processor) notifyAnnounce(status *gtsmodel.Status) error { } // now create the new reblog notification + notifID, err := id.NewULID() + if err != nil { + return err + } + notif := &gtsmodel.Notification{ + ID: notifID, NotificationType: gtsmodel.NotificationReblog, TargetAccountID: boostedAcct.ID, OriginAccountID: status.AccountID, @@ -211,3 +244,94 @@ func (p *processor) notifyAnnounce(status *gtsmodel.Status) error { return nil } + +func (p *processor) timelineStatus(status *gtsmodel.Status) error { + // make sure the author account is pinned onto the status + if status.GTSAuthorAccount == nil { + a := &gtsmodel.Account{} + if err := p.db.GetByID(status.AccountID, a); err != nil { + return fmt.Errorf("timelineStatus: error getting author account with id %s: %s", status.AccountID, err) + } + status.GTSAuthorAccount = a + } + + // get all relevant accounts here once + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(status) + if err != nil { + return fmt.Errorf("timelineStatus: error getting relevant accounts from status: %s", err) + } + + // get local followers of the account that posted the status + followers := []gtsmodel.Follow{} + if err := p.db.GetFollowersByAccountID(status.AccountID, &followers, true); err != nil { + return fmt.Errorf("timelineStatus: error getting followers for account id %s: %s", status.AccountID, err) + } + + // if the poster is local, add a fake entry for them to the followers list so they can see their own status in their timeline + if status.GTSAuthorAccount.Domain == "" { + followers = append(followers, gtsmodel.Follow{ + AccountID: status.AccountID, + }) + } + + wg := sync.WaitGroup{} + wg.Add(len(followers)) + errors := make(chan error, len(followers)) + + for _, f := range followers { + go p.timelineStatusForAccount(status, f.AccountID, relevantAccounts, errors, &wg) + } + + // read any errors that come in from the async functions + errs := []string{} + go func() { + for range errors { + e := <-errors + if e != nil { + errs = append(errs, e.Error()) + } + } + }() + + // wait til all functions have returned and then close the error channel + wg.Wait() + close(errors) + + if len(errs) != 0 { + // we have some errors + return fmt.Errorf("timelineStatus: one or more errors timelining statuses: %s", strings.Join(errs, ";")) + } + + // no errors, nice + return nil +} + +func (p *processor) timelineStatusForAccount(status *gtsmodel.Status, accountID string, relevantAccounts *gtsmodel.RelevantAccounts, errors chan error, wg *sync.WaitGroup) { + defer wg.Done() + + // get the targetAccount + timelineAccount := &gtsmodel.Account{} + if err := p.db.GetByID(accountID, timelineAccount); err != nil { + errors <- fmt.Errorf("timelineStatus: error getting account for timeline with id %s: %s", accountID, err) + return + } + + // make sure the status is visible + visible, err := p.db.StatusVisible(status, timelineAccount, relevantAccounts) + if err != nil { + errors <- fmt.Errorf("timelineStatus: error getting visibility for status for timeline with id %s: %s", accountID, err) + return + } + + if !visible { + return + } + + if err := p.timelineManager.IngestAndPrepare(status, timelineAccount.ID); err != nil { + errors <- fmt.Errorf("initTimelineFor: error ingesting status %s: %s", status.ID, err) + } +} + +func (p *processor) deleteStatusFromTimelines(status *gtsmodel.Status) error { + return p.timelineManager.WipeStatusFromAllTimelines(status.ID) +} diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go @@ -23,10 +23,10 @@ import ( "fmt" "net/url" - "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" ) func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) error { @@ -56,9 +56,14 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er return fmt.Errorf("error updating dereferenced status in the db: %s", err) } + if err := p.timelineStatus(incomingStatus); err != nil { + return err + } + if err := p.notifyStatus(incomingStatus); err != nil { return err } + case gtsmodel.ActivityStreamsProfile: // CREATE AN ACCOUNT incomingAccount, ok := federatorMsg.GTSModel.(*gtsmodel.Account) @@ -104,6 +109,12 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er return fmt.Errorf("error dereferencing announce from federator: %s", err) } + incomingAnnounceID, err := id.NewULIDFromTime(incomingAnnounce.CreatedAt) + if err != nil { + return err + } + incomingAnnounce.ID = incomingAnnounceID + if err := p.db.Put(incomingAnnounce); err != nil { if _, ok := err.(db.ErrAlreadyExists); !ok { return fmt.Errorf("error adding dereferenced announce to the db: %s", err) @@ -141,6 +152,11 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er // 1. delete all media associated with status // 2. delete boosts of status // 3. etc etc etc + statusToDelete, ok := federatorMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("note was not parseable as *gtsmodel.Status") + } + return p.deleteStatusFromTimelines(statusToDelete) case gtsmodel.ActivityStreamsProfile: // DELETE A PROFILE/ACCOUNT // TODO: handle side effects of account deletion here: delete all objects, statuses, media etc associated with account @@ -202,7 +218,11 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status, requestingU // the status should have an ID by now, but just in case it doesn't let's generate one here // because we'll need it further down if status.ID == "" { - status.ID = uuid.NewString() + newID, err := id.NewULIDFromTime(status.CreatedAt) + if err != nil { + return err + } + status.ID = newID } // 1. Media attachments. @@ -257,6 +277,14 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status, requestingU // We should dereference any accounts mentioned here which we don't have in our db yet, by their URI. mentions := []string{} for _, m := range status.GTSMentions { + if m.ID == "" { + mID, err := id.NewRandomULID() + if err != nil { + return err + } + m.ID = mID + } + uri, err := url.Parse(m.MentionedAccountURI) if err != nil { l.Debugf("error parsing mentioned account uri %s: %s", m.MentionedAccountURI, err) @@ -288,6 +316,12 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status, requestingU continue } + targetAccountID, err := id.NewRandomULID() + if err != nil { + return err + } + targetAccount.ID = targetAccountID + if err := p.db.Put(targetAccount); err != nil { return fmt.Errorf("db error inserting account with uri %s", uri.String()) } @@ -354,12 +388,12 @@ func (p *processor) dereferenceAnnounce(announce *gtsmodel.Status, requestingUse } // we don't have it so we need to dereference it - remoteStatusID, err := url.Parse(announce.GTSBoostedStatus.URI) + remoteStatusURI, err := url.Parse(announce.GTSBoostedStatus.URI) if err != nil { return fmt.Errorf("dereferenceAnnounce: error parsing url %s: %s", announce.GTSBoostedStatus.URI, err) } - statusable, err := p.federator.DereferenceRemoteStatus(requestingUsername, remoteStatusID) + statusable, err := p.federator.DereferenceRemoteStatus(requestingUsername, remoteStatusURI) if err != nil { return fmt.Errorf("dereferenceAnnounce: error dereferencing remote status with id %s: %s", announce.GTSBoostedStatus.URI, err) } @@ -387,7 +421,12 @@ func (p *processor) dereferenceAnnounce(announce *gtsmodel.Status, requestingUse return fmt.Errorf("dereferenceAnnounce: error converting dereferenced account with id %s into account : %s", accountURI.String(), err) } - // insert the dereferenced account so it gets an ID etc + accountID, err := id.NewRandomULID() + if err != nil { + return err + } + account.ID = accountID + if err := p.db.Put(account); err != nil { return fmt.Errorf("dereferenceAnnounce: error putting dereferenced account with id %s into database : %s", accountURI.String(), err) } @@ -403,7 +442,12 @@ func (p *processor) dereferenceAnnounce(announce *gtsmodel.Status, requestingUse return fmt.Errorf("dereferenceAnnounce: error converting dereferenced statusable with id %s into status : %s", announce.GTSBoostedStatus.URI, err) } - // put it in the db already so it gets an ID generated for it + boostedStatusID, err := id.NewULIDFromTime(boostedStatus.CreatedAt) + if err != nil { + return nil + } + boostedStatus.ID = boostedStatusID + if err := p.db.Put(boostedStatus); err != nil { return fmt.Errorf("dereferenceAnnounce: error putting dereferenced status with id %s into the db: %s", announce.GTSBoostedStatus.URI, err) } diff --git a/internal/processing/instance.go b/internal/processing/instance.go @@ -23,18 +23,19 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -func (p *processor) InstanceGet(domain string) (*apimodel.Instance, ErrorWithCode) { +func (p *processor) InstanceGet(domain string) (*apimodel.Instance, gtserror.WithCode) { i := &gtsmodel.Instance{} if err := p.db.GetWhere([]db.Where{{Key: "domain", Value: domain}}, i); err != nil { - return nil, NewErrorInternalError(fmt.Errorf("db error fetching instance %s: %s", p.config.Host, err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance %s: %s", p.config.Host, err)) } ai, err := p.tc.InstanceToMasto(i) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error converting instance to api representation: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting instance to api representation: %s", err)) } return ai, nil diff --git a/internal/processing/media.go b/internal/processing/media.go @@ -28,6 +28,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -92,64 +93,64 @@ func (p *processor) MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentReq return &mastoAttachment, nil } -func (p *processor) MediaGet(authed *oauth.Auth, mediaAttachmentID string) (*apimodel.Attachment, ErrorWithCode) { +func (p *processor) MediaGet(authed *oauth.Auth, mediaAttachmentID string) (*apimodel.Attachment, gtserror.WithCode) { attachment := &gtsmodel.MediaAttachment{} if err := p.db.GetByID(mediaAttachmentID, attachment); err != nil { if _, ok := err.(db.ErrNoEntries); ok { // attachment doesn't exist - return nil, NewErrorNotFound(errors.New("attachment doesn't exist in the db")) + return nil, gtserror.NewErrorNotFound(errors.New("attachment doesn't exist in the db")) } - return nil, NewErrorNotFound(fmt.Errorf("db error getting attachment: %s", err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error getting attachment: %s", err)) } if attachment.AccountID != authed.Account.ID { - return nil, NewErrorNotFound(errors.New("attachment not owned by requesting account")) + return nil, gtserror.NewErrorNotFound(errors.New("attachment not owned by requesting account")) } a, err := p.tc.AttachmentToMasto(attachment) if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) } return &a, nil } -func (p *processor) MediaUpdate(authed *oauth.Auth, mediaAttachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, ErrorWithCode) { +func (p *processor) MediaUpdate(authed *oauth.Auth, mediaAttachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode) { attachment := &gtsmodel.MediaAttachment{} if err := p.db.GetByID(mediaAttachmentID, attachment); err != nil { if _, ok := err.(db.ErrNoEntries); ok { // attachment doesn't exist - return nil, NewErrorNotFound(errors.New("attachment doesn't exist in the db")) + return nil, gtserror.NewErrorNotFound(errors.New("attachment doesn't exist in the db")) } - return nil, NewErrorNotFound(fmt.Errorf("db error getting attachment: %s", err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error getting attachment: %s", err)) } if attachment.AccountID != authed.Account.ID { - return nil, NewErrorNotFound(errors.New("attachment not owned by requesting account")) + return nil, gtserror.NewErrorNotFound(errors.New("attachment not owned by requesting account")) } if form.Description != nil { attachment.Description = *form.Description if err := p.db.UpdateByID(mediaAttachmentID, attachment); err != nil { - return nil, NewErrorInternalError(fmt.Errorf("database error updating description: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("database error updating description: %s", err)) } } if form.Focus != nil { focusx, focusy, err := parseFocus(*form.Focus) if err != nil { - return nil, NewErrorBadRequest(err) + return nil, gtserror.NewErrorBadRequest(err) } attachment.FileMeta.Focus.X = focusx attachment.FileMeta.Focus.Y = focusy if err := p.db.UpdateByID(mediaAttachmentID, attachment); err != nil { - return nil, NewErrorInternalError(fmt.Errorf("database error updating focus: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("database error updating focus: %s", err)) } } a, err := p.tc.AttachmentToMasto(attachment) if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) } return &a, nil @@ -159,37 +160,37 @@ func (p *processor) FileGet(authed *oauth.Auth, form *apimodel.GetContentRequest // parse the form fields mediaSize, err := media.ParseMediaSize(form.MediaSize) if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize)) } mediaType, err := media.ParseMediaType(form.MediaType) if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("media type %s not valid", form.MediaType)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("media type %s not valid", form.MediaType)) } spl := strings.Split(form.FileName, ".") if len(spl) != 2 || spl[0] == "" || spl[1] == "" { - return nil, NewErrorNotFound(fmt.Errorf("file name %s not parseable", form.FileName)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("file name %s not parseable", form.FileName)) } wantedMediaID := spl[0] // get the account that owns the media and make sure it's not suspended acct := &gtsmodel.Account{} if err := p.db.GetByID(form.AccountID, acct); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", form.AccountID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", form.AccountID, err)) } if !acct.SuspendedAt.IsZero() { - return nil, NewErrorNotFound(fmt.Errorf("account with id %s is suspended", form.AccountID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("account with id %s is suspended", form.AccountID)) } // make sure the requesting account and the media account don't block each other if authed.Account != nil { blocked, err := p.db.Blocked(authed.Account.ID, form.AccountID) if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", form.AccountID, authed.Account.ID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", form.AccountID, authed.Account.ID, err)) } if blocked { - return nil, NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", form.AccountID, authed.Account.ID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", form.AccountID, authed.Account.ID)) } } @@ -201,10 +202,10 @@ func (p *processor) FileGet(authed *oauth.Auth, form *apimodel.GetContentRequest case media.Emoji: e := &gtsmodel.Emoji{} if err := p.db.GetByID(wantedMediaID, e); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", wantedMediaID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", wantedMediaID, err)) } if e.Disabled { - return nil, NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", wantedMediaID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", wantedMediaID)) } switch mediaSize { case media.Original: @@ -214,15 +215,15 @@ func (p *processor) FileGet(authed *oauth.Auth, form *apimodel.GetContentRequest content.ContentType = e.ImageStaticContentType storagePath = e.ImageStaticPath default: - return nil, NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", mediaSize)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", mediaSize)) } case media.Attachment, media.Header, media.Avatar: a := &gtsmodel.MediaAttachment{} if err := p.db.GetByID(wantedMediaID, a); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err)) } if a.AccountID != form.AccountID { - return nil, NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, form.AccountID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, form.AccountID)) } switch mediaSize { case media.Original: @@ -232,13 +233,13 @@ func (p *processor) FileGet(authed *oauth.Auth, form *apimodel.GetContentRequest content.ContentType = a.Thumbnail.ContentType storagePath = a.Thumbnail.Path default: - return nil, NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) } } bytes, err := p.storage.RetrieveFileFrom(storagePath) if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) } content.ContentLength = int64(len(bytes)) diff --git a/internal/processing/notification.go b/internal/processing/notification.go @@ -20,15 +20,16 @@ package processing import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *processor) NotificationsGet(authed *oauth.Auth, limit int, maxID string, sinceID string) ([]*apimodel.Notification, ErrorWithCode) { +func (p *processor) NotificationsGet(authed *oauth.Auth, limit int, maxID string, sinceID string) ([]*apimodel.Notification, gtserror.WithCode) { l := p.log.WithField("func", "NotificationsGet") notifs, err := p.db.GetNotificationsForAccount(authed.Account.ID, limit, maxID, sinceID) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } mastoNotifs := []*apimodel.Notification{} diff --git a/internal/processing/processor.go b/internal/processing/processor.go @@ -28,9 +28,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing/synchronous/status" + "github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -41,14 +44,6 @@ import ( // fire messages into the processor and not wait for a reply before proceeding with other work. This allows // for clean distribution of messages without slowing down the client API and harming the user experience. type Processor interface { - // ToClientAPI returns a channel for putting in messages that need to go to the gts client API. - // ToClientAPI() chan gtsmodel.ToClientAPI - // FromClientAPI returns a channel for putting messages in that come from the client api going to the processor - FromClientAPI() chan gtsmodel.FromClientAPI - // ToFederator returns a channel for putting in messages that need to go to the federator (activitypub). - // ToFederator() chan gtsmodel.ToFederator - // FromFederator returns a channel for putting messages in that come from the federator (activitypub) going into the processor - FromFederator() chan gtsmodel.FromFederator // Start starts the Processor, reading from its channels and passing messages back and forth. Start() error // Stop stops the processor cleanly, finishing handling any remaining messages before closing down. @@ -70,17 +65,17 @@ type Processor interface { AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) // AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for // the account given in authed. - AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, ErrorWithCode) + AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) // AccountFollowersGet fetches a list of the target account's followers. - AccountFollowersGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, ErrorWithCode) + AccountFollowersGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) // AccountFollowingGet fetches a list of the accounts that target account is following. - AccountFollowingGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, ErrorWithCode) + AccountFollowingGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) // AccountRelationshipGet returns a relationship model describing the relationship of the targetAccount to the Authed account. - AccountRelationshipGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, ErrorWithCode) + AccountRelationshipGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) // AccountFollowCreate handles a follow request to an account, either remote or local. - AccountFollowCreate(authed *oauth.Auth, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, ErrorWithCode) + AccountFollowCreate(authed *oauth.Auth, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, gtserror.WithCode) // AccountFollowRemove handles the removal of a follow/follow request to an account, either remote or local. - AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, ErrorWithCode) + AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) // AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form. AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) @@ -92,25 +87,25 @@ type Processor interface { FileGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) // FollowRequestsGet handles the getting of the authed account's incoming follow requests - FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, ErrorWithCode) + FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, gtserror.WithCode) // FollowRequestAccept handles the acceptance of a follow request from the given account ID - FollowRequestAccept(auth *oauth.Auth, accountID string) (*apimodel.Relationship, ErrorWithCode) + FollowRequestAccept(auth *oauth.Auth, accountID string) (*apimodel.Relationship, gtserror.WithCode) // InstanceGet retrieves instance information for serving at api/v1/instance - InstanceGet(domain string) (*apimodel.Instance, ErrorWithCode) + InstanceGet(domain string) (*apimodel.Instance, gtserror.WithCode) // MediaCreate handles the creation of a media attachment, using the given form. MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) // MediaGet handles the GET of a media attachment with the given ID - MediaGet(authed *oauth.Auth, attachmentID string) (*apimodel.Attachment, ErrorWithCode) + MediaGet(authed *oauth.Auth, attachmentID string) (*apimodel.Attachment, gtserror.WithCode) // MediaUpdate handles the PUT of a media attachment with the given ID and form - MediaUpdate(authed *oauth.Auth, attachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, ErrorWithCode) + MediaUpdate(authed *oauth.Auth, attachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode) // NotificationsGet - NotificationsGet(authed *oauth.Auth, limit int, maxID string, sinceID string) ([]*apimodel.Notification, ErrorWithCode) + NotificationsGet(authed *oauth.Auth, limit int, maxID string, sinceID string) ([]*apimodel.Notification, gtserror.WithCode) // SearchGet performs a search with the given params, resolving/dereferencing remotely as desired - SearchGet(authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, ErrorWithCode) + SearchGet(authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, gtserror.WithCode) // StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK. StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) @@ -119,9 +114,9 @@ type Processor interface { // StatusFave processes the faving of a given status, returning the updated status if the fave goes through. StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) // StatusBoost processes the boost/reblog of a given status, returning the newly-created boost if all is well. - StatusBoost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, ErrorWithCode) + StatusBoost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // StatusBoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings. - StatusBoostedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, ErrorWithCode) + StatusBoostedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) // StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings. StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) // StatusGet gets the given status, taking account of privacy settings and blocks etc. @@ -129,12 +124,12 @@ type Processor interface { // StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through. StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) // StatusGetContext returns the context (previous and following posts) from the given status ID - StatusGetContext(authed *oauth.Auth, targetStatusID string) (*apimodel.Context, ErrorWithCode) + StatusGetContext(authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) // HomeTimelineGet returns statuses from the home timeline, with the given filters/parameters. - HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]apimodel.Status, ErrorWithCode) + HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) // PublicTimelineGet returns statuses from the public/local timeline, with the given filters/parameters. - PublicTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]apimodel.Status, ErrorWithCode) + PublicTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, gtserror.WithCode) /* FEDERATION API-FACING PROCESSING FUNCTIONS @@ -146,22 +141,22 @@ type Processor interface { // GetFediUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication // before returning a JSON serializable interface to the caller. - GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) + GetFediUser(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) // GetFediFollowers handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate // authentication before returning a JSON serializable interface to the caller. - GetFediFollowers(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) + GetFediFollowers(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) // GetFediFollowing handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate // authentication before returning a JSON serializable interface to the caller. - GetFediFollowing(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) + GetFediFollowing(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) // GetFediStatus handles the getting of a fedi/activitypub representation of a particular status, performing appropriate // authentication before returning a JSON serializable interface to the caller. - GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, ErrorWithCode) + GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, gtserror.WithCode) // GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups. - GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode) + GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, gtserror.WithCode) // InboxPost handles POST requests to a user's inbox for new activitypub messages. // @@ -178,55 +173,50 @@ type Processor interface { // processor just implements the Processor interface type processor struct { - // federator pub.FederatingActor - // toClientAPI chan gtsmodel.ToClientAPI - fromClientAPI chan gtsmodel.FromClientAPI - // toFederator chan gtsmodel.ToFederator - fromFederator chan gtsmodel.FromFederator - federator federation.Federator - stop chan interface{} - log *logrus.Logger - config *config.Config - tc typeutils.TypeConverter - oauthServer oauth.Server - mediaHandler media.Handler - storage blob.Storage - db db.DB -} + fromClientAPI chan gtsmodel.FromClientAPI + fromFederator chan gtsmodel.FromFederator + federator federation.Federator + stop chan interface{} + log *logrus.Logger + config *config.Config + tc typeutils.TypeConverter + oauthServer oauth.Server + mediaHandler media.Handler + storage blob.Storage + timelineManager timeline.Manager + db db.DB -// NewProcessor returns a new Processor that uses the given federator and logger -func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator federation.Federator, oauthServer oauth.Server, mediaHandler media.Handler, storage blob.Storage, db db.DB, log *logrus.Logger) Processor { - return &processor{ - // toClientAPI: make(chan gtsmodel.ToClientAPI, 100), - fromClientAPI: make(chan gtsmodel.FromClientAPI, 100), - // toFederator: make(chan gtsmodel.ToFederator, 100), - fromFederator: make(chan gtsmodel.FromFederator, 100), - federator: federator, - stop: make(chan interface{}), - log: log, - config: config, - tc: tc, - oauthServer: oauthServer, - mediaHandler: mediaHandler, - storage: storage, - db: db, - } + /* + SUB-PROCESSORS + */ + + statusProcessor status.Processor } -// func (p *processor) ToClientAPI() chan gtsmodel.ToClientAPI { -// return p.toClientAPI -// } +// NewProcessor returns a new Processor that uses the given federator and logger +func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator federation.Federator, oauthServer oauth.Server, mediaHandler media.Handler, storage blob.Storage, timelineManager timeline.Manager, db db.DB, log *logrus.Logger) Processor { -func (p *processor) FromClientAPI() chan gtsmodel.FromClientAPI { - return p.fromClientAPI -} + fromClientAPI := make(chan gtsmodel.FromClientAPI, 1000) + fromFederator := make(chan gtsmodel.FromFederator, 1000) -// func (p *processor) ToFederator() chan gtsmodel.ToFederator { -// return p.toFederator -// } + statusProcessor := status.New(db, tc, config, fromClientAPI, log) -func (p *processor) FromFederator() chan gtsmodel.FromFederator { - return p.fromFederator + return &processor{ + fromClientAPI: fromClientAPI, + fromFederator: fromFederator, + federator: federator, + stop: make(chan interface{}), + log: log, + config: config, + tc: tc, + oauthServer: oauthServer, + mediaHandler: mediaHandler, + storage: storage, + timelineManager: timelineManager, + db: db, + + statusProcessor: statusProcessor, + } } // Start starts the Processor, reading from its channels and passing messages back and forth. @@ -250,7 +240,7 @@ func (p *processor) Start() error { } } }() - return nil + return p.initTimelines() } // Stop stops the processor cleanly, finishing handling any remaining messages before closing down. diff --git a/internal/processing/search.go b/internal/processing/search.go @@ -27,12 +27,14 @@ import ( "github.com/sirupsen/logrus" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/util" ) -func (p *processor) SearchGet(authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, ErrorWithCode) { +func (p *processor) SearchGet(authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, gtserror.WithCode) { l := p.log.WithFields(logrus.Fields{ "func": "SearchGet", "query": searchQuery.Query, @@ -108,7 +110,7 @@ func (p *processor) SearchGet(authed *oauth.Auth, searchQuery *apimodel.SearchQu if err != nil { continue } - if visible, err := p.db.StatusVisible(foundStatus, statusOwner, authed.Account, relevantAccounts); !visible || err != nil { + if visible, err := p.db.StatusVisible(foundStatus, authed.Account, relevantAccounts); !visible || err != nil { continue } @@ -164,10 +166,15 @@ func (p *processor) searchStatusByURI(authed *oauth.Auth, uri *url.URL, resolve // first turn it into a gtsmodel.Status status, err := p.tc.ASStatusToStatus(statusable) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } - // put it in the DB so it gets a UUID + statusID, err := id.NewULIDFromTime(status.CreatedAt) + if err != nil { + return nil, err + } + status.ID = statusID + if err := p.db.Put(status); err != nil { return nil, fmt.Errorf("error putting status in the db: %s", err) } @@ -210,6 +217,12 @@ func (p *processor) searchAccountByURI(authed *oauth.Auth, uri *url.URL, resolve return nil, fmt.Errorf("searchAccountByURI: error dereferencing account with uri %s: %s", uri.String(), err) } + accountID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + account.ID = accountID + if err := p.db.Put(account); err != nil { return nil, fmt.Errorf("searchAccountByURI: error inserting account with uri %s: %s", uri.String(), err) } @@ -280,6 +293,12 @@ func (p *processor) searchAccountByMention(authed *oauth.Auth, mention string, r return nil, fmt.Errorf("searchAccountByMention: error converting account with uri %s: %s", acctURI.String(), err) } + foundAccountID, err := id.NewULID() + if err != nil { + return nil, err + } + foundAccount.ID = foundAccountID + // put this new account in our database if err := p.db.Put(foundAccount); err != nil { return nil, fmt.Errorf("searchAccountByMention: error inserting account with uri %s: %s", acctURI.String(), err) diff --git a/internal/processing/status.go b/internal/processing/status.go @@ -19,531 +19,43 @@ package processing import ( - "errors" - "fmt" - "time" - - "github.com/google/uuid" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" ) -func (p *processor) StatusCreate(auth *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) { - uris := util.GenerateURIsForAccount(auth.Account.Username, p.config.Protocol, p.config.Host) - thisStatusID := uuid.NewString() - thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) - thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) - newStatus := &gtsmodel.Status{ - ID: thisStatusID, - URI: thisStatusURI, - URL: thisStatusURL, - Content: util.HTMLFormat(form.Status), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Local: true, - AccountID: auth.Account.ID, - ContentWarning: form.SpoilerText, - ActivityStreamsType: gtsmodel.ActivityStreamsNote, - Sensitive: form.Sensitive, - Language: form.Language, - CreatedWithApplicationID: auth.Application.ID, - Text: form.Status, - } - - // check if replyToID is ok - if err := p.processReplyToID(form, auth.Account.ID, newStatus); err != nil { - return nil, err - } - - // check if mediaIDs are ok - if err := p.processMediaIDs(form, auth.Account.ID, newStatus); err != nil { - return nil, err - } - - // check if visibility settings are ok - if err := p.processVisibility(form, auth.Account.Privacy, newStatus); err != nil { - return nil, err - } - - // handle language settings - if err := p.processLanguage(form, auth.Account.Language, newStatus); err != nil { - return nil, err - } - - // handle mentions - if err := p.processMentions(form, auth.Account.ID, newStatus); err != nil { - return nil, err - } - - if err := p.processTags(form, auth.Account.ID, newStatus); err != nil { - return nil, err - } - - if err := p.processEmojis(form, auth.Account.ID, newStatus); err != nil { - return nil, err - } - - // put the new status in the database, generating an ID for it in the process - if err := p.db.Put(newStatus); err != nil { - return nil, err - } - - // change the status ID of the media attachments to the new status - for _, a := range newStatus.GTSMediaAttachments { - a.StatusID = newStatus.ID - a.UpdatedAt = time.Now() - if err := p.db.UpdateByID(a.ID, a); err != nil { - return nil, err - } - } - - // put the new status in the appropriate channel for async processing - p.fromClientAPI <- gtsmodel.FromClientAPI{ - APObjectType: newStatus.ActivityStreamsType, - APActivityType: gtsmodel.ActivityStreamsCreate, - GTSModel: newStatus, - } - - // return the frontend representation of the new status to the submitter - return p.tc.StatusToMasto(newStatus, auth.Account, auth.Account, nil, newStatus.GTSReplyToAccount, nil) +func (p *processor) StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) { + return p.statusProcessor.Create(authed.Account, authed.Application, form) } func (p *processor) StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { - l := p.log.WithField("func", "StatusDelete") - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := &gtsmodel.Status{} - if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { - return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) - } - - if targetStatus.AccountID != authed.Account.ID { - return nil, errors.New("status doesn't belong to requesting account") - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = &gtsmodel.Status{} - if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - } - } - - mastoStatus, err := p.tc.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - } - - if err := p.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { - return nil, fmt.Errorf("error deleting status from the database: %s", err) - } - - return mastoStatus, nil + return p.statusProcessor.Delete(authed.Account, targetStatusID) } func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { - l := p.log.WithField("func", "StatusFave") - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := &gtsmodel.Status{} - if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { - return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := &gtsmodel.Account{} - if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = &gtsmodel.Status{} - if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - } - } - - l.Trace("going to see if status is visible") - visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - } - - if !visible { - return nil, errors.New("status is not visible") - } - - // is the status faveable? - if targetStatus.VisibilityAdvanced != nil { - if !targetStatus.VisibilityAdvanced.Likeable { - return nil, errors.New("status is not faveable") - } - } - - // first check if the status is already faved, if so we don't need to do anything - newFave := true - gtsFave := &gtsmodel.Status{} - if err := p.db.GetWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: authed.Account.ID}}, gtsFave); err == nil { - // we already have a fave for this status - newFave = false - } - - if newFave { - thisFaveID := uuid.NewString() - - // we need to create a new fave in the database - gtsFave := &gtsmodel.StatusFave{ - ID: thisFaveID, - AccountID: authed.Account.ID, - TargetAccountID: targetAccount.ID, - StatusID: targetStatus.ID, - URI: util.GenerateURIForLike(authed.Account.Username, p.config.Protocol, p.config.Host, thisFaveID), - GTSStatus: targetStatus, - GTSTargetAccount: targetAccount, - GTSFavingAccount: authed.Account, - } - - if err := p.db.Put(gtsFave); err != nil { - return nil, err - } - - // send the new fave through the processor channel for federation etc - p.fromClientAPI <- gtsmodel.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsLike, - APActivityType: gtsmodel.ActivityStreamsCreate, - GTSModel: gtsFave, - OriginAccount: authed.Account, - TargetAccount: targetAccount, - } - } - - // return the mastodon representation of the target status - mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - } - - return mastoStatus, nil + return p.statusProcessor.Fave(authed.Account, targetStatusID) } -func (p *processor) StatusBoost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, ErrorWithCode) { - l := p.log.WithField("func", "StatusBoost") - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := &gtsmodel.Status{} - if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := &gtsmodel.Account{} - if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) - } - - l.Trace("going to see if status is visible") - visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - - if !visible { - return nil, NewErrorNotFound(errors.New("status is not visible")) - } - - if targetStatus.VisibilityAdvanced != nil { - if !targetStatus.VisibilityAdvanced.Boostable { - return nil, NewErrorForbidden(errors.New("status is not boostable")) - } - } - - // it's visible! it's boostable! so let's boost the FUCK out of it - boostWrapperStatus, err := p.tc.StatusToBoost(targetStatus, authed.Account) - if err != nil { - return nil, NewErrorInternalError(err) - } - - boostWrapperStatus.CreatedWithApplicationID = authed.Application.ID - boostWrapperStatus.GTSBoostedAccount = targetAccount - - // put the boost in the database - if err := p.db.Put(boostWrapperStatus); err != nil { - return nil, NewErrorInternalError(err) - } - - // send it to the processor for async processing - p.fromClientAPI <- gtsmodel.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsAnnounce, - APActivityType: gtsmodel.ActivityStreamsCreate, - GTSModel: boostWrapperStatus, - OriginAccount: authed.Account, - TargetAccount: targetAccount, - } - - // return the frontend representation of the new status to the submitter - mastoStatus, err := p.tc.StatusToMasto(boostWrapperStatus, authed.Account, authed.Account, targetAccount, nil, targetStatus) - if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) - } - - return mastoStatus, nil +func (p *processor) StatusBoost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + return p.statusProcessor.Boost(authed.Account, authed.Application, targetStatusID) } -func (p *processor) StatusBoostedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, ErrorWithCode) { - l := p.log.WithField("func", "StatusBoostedBy") - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := &gtsmodel.Status{} - if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching status %s: %s", targetStatusID, err)) - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := &gtsmodel.Account{} - if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - return nil, NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching target account %s: %s", targetStatus.AccountID, err)) - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching related accounts for status %s: %s", targetStatusID, err)) - } - - l.Trace("going to see if status is visible") - visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - - if !visible { - return nil, NewErrorNotFound(errors.New("StatusBoostedBy: status is not visible")) - } - - // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff - favingAccounts, err := p.db.WhoBoostedStatus(targetStatus) - if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error seeing who boosted status: %s", err)) - } - - // filter the list so the user doesn't see accounts they blocked or which blocked them - filteredAccounts := []*gtsmodel.Account{} - for _, acc := range favingAccounts { - blocked, err := p.db.Blocked(authed.Account.ID, acc.ID) - if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error checking blocks: %s", err)) - } - if !blocked { - filteredAccounts = append(filteredAccounts, acc) - } - } - - // TODO: filter other things here? suspended? muted? silenced? - - // now we can return the masto representation of those accounts - mastoAccounts := []*apimodel.Account{} - for _, acc := range filteredAccounts { - mastoAccount, err := p.tc.AccountToMastoPublic(acc) - if err != nil { - return nil, NewErrorNotFound(fmt.Errorf("StatusFavedBy: error converting account to api model: %s", err)) - } - mastoAccounts = append(mastoAccounts, mastoAccount) - } - - return mastoAccounts, nil +func (p *processor) StatusBoostedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) { + return p.statusProcessor.BoostedBy(authed.Account, targetStatusID) } func (p *processor) StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) { - l := p.log.WithField("func", "StatusFavedBy") - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := &gtsmodel.Status{} - if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { - return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := &gtsmodel.Account{} - if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - } - - l.Trace("going to see if status is visible") - visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - } - - if !visible { - return nil, errors.New("status is not visible") - } - - // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff - favingAccounts, err := p.db.WhoFavedStatus(targetStatus) - if err != nil { - return nil, fmt.Errorf("error seeing who faved status: %s", err) - } - - // filter the list so the user doesn't see accounts they blocked or which blocked them - filteredAccounts := []*gtsmodel.Account{} - for _, acc := range favingAccounts { - blocked, err := p.db.Blocked(authed.Account.ID, acc.ID) - if err != nil { - return nil, fmt.Errorf("error checking blocks: %s", err) - } - if !blocked { - filteredAccounts = append(filteredAccounts, acc) - } - } - - // TODO: filter other things here? suspended? muted? silenced? - - // now we can return the masto representation of those accounts - mastoAccounts := []*apimodel.Account{} - for _, acc := range filteredAccounts { - mastoAccount, err := p.tc.AccountToMastoPublic(acc) - if err != nil { - return nil, fmt.Errorf("error converting account to api model: %s", err) - } - mastoAccounts = append(mastoAccounts, mastoAccount) - } - - return mastoAccounts, nil + return p.statusProcessor.FavedBy(authed.Account, targetStatusID) } func (p *processor) StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { - l := p.log.WithField("func", "StatusGet") - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := &gtsmodel.Status{} - if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { - return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := &gtsmodel.Account{} - if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - } - - l.Trace("going to see if status is visible") - visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - } - - if !visible { - return nil, errors.New("status is not visible") - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = &gtsmodel.Status{} - if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - } - } - - mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - } - - return mastoStatus, nil - + return p.statusProcessor.Get(authed.Account, targetStatusID) } func (p *processor) StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { - l := p.log.WithField("func", "StatusUnfave") - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := &gtsmodel.Status{} - if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { - return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := &gtsmodel.Account{} - if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - } - - l.Trace("going to see if status is visible") - visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - } - - if !visible { - return nil, errors.New("status is not visible") - } - - // is the status faveable? - if targetStatus.VisibilityAdvanced != nil { - if !targetStatus.VisibilityAdvanced.Likeable { - return nil, errors.New("status is not faveable") - } - } - - // it's visible! it's faveable! so let's unfave the FUCK out of it - _, err = p.db.UnfaveStatus(targetStatus, authed.Account.ID) - if err != nil { - return nil, fmt.Errorf("error unfaveing status: %s", err) - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = &gtsmodel.Status{} - if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - } - } - - mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - } - - return mastoStatus, nil + return p.statusProcessor.Unfave(authed.Account, targetStatusID) } -func (p *processor) StatusGetContext(authed *oauth.Auth, targetStatusID string) (*apimodel.Context, ErrorWithCode) { - return &apimodel.Context{}, nil +func (p *processor) StatusGetContext(authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { + return p.statusProcessor.Context(authed.Account, targetStatusID) } diff --git a/internal/processing/synchronous/status/boost.go b/internal/processing/synchronous/status/boost.go @@ -0,0 +1,79 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Boost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + l := p.log.WithField("func", "StatusBoost") + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + } + + if targetStatus.VisibilityAdvanced != nil { + if !targetStatus.VisibilityAdvanced.Boostable { + return nil, gtserror.NewErrorForbidden(errors.New("status is not boostable")) + } + } + + // it's visible! it's boostable! so let's boost the FUCK out of it + boostWrapperStatus, err := p.tc.StatusToBoost(targetStatus, account) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + boostWrapperStatus.CreatedWithApplicationID = application.ID + boostWrapperStatus.GTSBoostedAccount = targetAccount + + // put the boost in the database + if err := p.db.Put(boostWrapperStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // send it back to the processor for async processing + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsAnnounce, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: boostWrapperStatus, + OriginAccount: account, + TargetAccount: targetAccount, + } + + // return the frontend representation of the new status to the submitter + mastoStatus, err := p.tc.StatusToMasto(boostWrapperStatus, account, account, targetAccount, nil, targetStatus) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + return mastoStatus, nil +} diff --git a/internal/processing/synchronous/status/boostedby.go b/internal/processing/synchronous/status/boostedby.go @@ -0,0 +1,74 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) BoostedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) { + l := p.log.WithField("func", "StatusBoostedBy") + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching status %s: %s", targetStatusID, err)) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching target account %s: %s", targetStatus.AccountID, err)) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching related accounts for status %s: %s", targetStatusID, err)) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("StatusBoostedBy: status is not visible")) + } + + // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff + favingAccounts, err := p.db.WhoBoostedStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error seeing who boosted status: %s", err)) + } + + // filter the list so the user doesn't see accounts they blocked or which blocked them + filteredAccounts := []*gtsmodel.Account{} + for _, acc := range favingAccounts { + blocked, err := p.db.Blocked(account.ID, acc.ID) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error checking blocks: %s", err)) + } + if !blocked { + filteredAccounts = append(filteredAccounts, acc) + } + } + + // TODO: filter other things here? suspended? muted? silenced? + + // now we can return the masto representation of those accounts + mastoAccounts := []*apimodel.Account{} + for _, acc := range filteredAccounts { + mastoAccount, err := p.tc.AccountToMastoPublic(acc) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusFavedBy: error converting account to api model: %s", err)) + } + mastoAccounts = append(mastoAccounts, mastoAccount) + } + + return mastoAccounts, nil +} diff --git a/internal/processing/synchronous/status/context.go b/internal/processing/synchronous/status/context.go @@ -0,0 +1,14 @@ +package status + +import ( + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Context(account *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { + return &apimodel.Context{ + Ancestors: []apimodel.Status{}, + Descendants: []apimodel.Status{}, + }, nil +} diff --git a/internal/processing/synchronous/status/create.go b/internal/processing/synchronous/status/create.go @@ -0,0 +1,105 @@ +package status + +import ( + "fmt" + "time" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) Create(account *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) { + uris := util.GenerateURIsForAccount(account.Username, p.config.Protocol, p.config.Host) + thisStatusID, err := id.NewULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) + thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) + + newStatus := &gtsmodel.Status{ + ID: thisStatusID, + URI: thisStatusURI, + URL: thisStatusURL, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Local: true, + AccountID: account.ID, + ContentWarning: form.SpoilerText, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + Sensitive: form.Sensitive, + Language: form.Language, + CreatedWithApplicationID: application.ID, + Text: form.Status, + } + + // check if replyToID is ok + if err := p.processReplyToID(form, account.ID, newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // check if mediaIDs are ok + if err := p.processMediaIDs(form, account.ID, newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // check if visibility settings are ok + if err := p.processVisibility(form, account.Privacy, newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // handle language settings + if err := p.processLanguage(form, account.Language, newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // handle mentions + if err := p.processMentions(form, account.ID, newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + if err := p.processTags(form, account.ID, newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + if err := p.processEmojis(form, account.ID, newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + if err := p.processContent(form, account.ID, newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // put the new status in the database, generating an ID for it in the process + if err := p.db.Put(newStatus); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // change the status ID of the media attachments to the new status + for _, a := range newStatus.GTSMediaAttachments { + a.StatusID = newStatus.ID + a.UpdatedAt = time.Now() + if err := p.db.UpdateByID(a.ID, a); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + } + + // send it back to the processor for async processing + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: newStatus, + OriginAccount: account, + } + + // return the frontend representation of the new status to the submitter + mastoStatus, err := p.tc.StatusToMasto(newStatus, account, account, nil, newStatus.GTSReplyToAccount, nil) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", newStatus.ID, err)) + } + + return mastoStatus, nil +} diff --git a/internal/processing/synchronous/status/delete.go b/internal/processing/synchronous/status/delete.go @@ -0,0 +1,61 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Delete(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + l := p.log.WithField("func", "StatusDelete") + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + // status is already gone + return nil, nil + } + + if targetStatus.AccountID != account.ID { + return nil, gtserror.NewErrorForbidden(errors.New("status doesn't belong to requesting account")) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, account, account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + if err := p.db.DeleteByID(targetStatus.ID, &gtsmodel.Status{}); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error deleting status from the database: %s", err)) + } + + // send it back to the processor for async processing + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsDelete, + GTSModel: targetStatus, + OriginAccount: account, + } + + return mastoStatus, nil +} diff --git a/internal/processing/synchronous/status/fave.go b/internal/processing/synchronous/status/fave.go @@ -0,0 +1,107 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) Fave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + l := p.log.WithField("func", "StatusFave") + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) + } + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + } + + // is the status faveable? + if targetStatus.VisibilityAdvanced != nil { + if !targetStatus.VisibilityAdvanced.Likeable { + return nil, gtserror.NewErrorForbidden(errors.New("status is not faveable")) + } + } + + // first check if the status is already faved, if so we don't need to do anything + newFave := true + gtsFave := &gtsmodel.StatusFave{} + if err := p.db.GetWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: account.ID}}, gtsFave); err == nil { + // we already have a fave for this status + newFave = false + } + + if newFave { + thisFaveID, err := id.NewRandomULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // we need to create a new fave in the database + gtsFave := &gtsmodel.StatusFave{ + ID: thisFaveID, + AccountID: account.ID, + TargetAccountID: targetAccount.ID, + StatusID: targetStatus.ID, + URI: util.GenerateURIForLike(account.Username, p.config.Protocol, p.config.Host, thisFaveID), + GTSStatus: targetStatus, + GTSTargetAccount: targetAccount, + GTSFavingAccount: account, + } + + if err := p.db.Put(gtsFave); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error putting fave in database: %s", err)) + } + + // send it back to the processor for async processing + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsLike, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: gtsFave, + OriginAccount: account, + TargetAccount: targetAccount, + } + } + + // return the mastodon representation of the target status + mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + return mastoStatus, nil +} diff --git a/internal/processing/synchronous/status/favedby.go b/internal/processing/synchronous/status/favedby.go @@ -0,0 +1,74 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) FavedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) { + l := p.log.WithField("func", "StatusFavedBy") + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + } + + // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff + favingAccounts, err := p.db.WhoFavedStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing who faved status: %s", err)) + } + + // filter the list so the user doesn't see accounts they blocked or which blocked them + filteredAccounts := []*gtsmodel.Account{} + for _, acc := range favingAccounts { + blocked, err := p.db.Blocked(account.ID, acc.ID) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking blocks: %s", err)) + } + if !blocked { + filteredAccounts = append(filteredAccounts, acc) + } + } + + // TODO: filter other things here? suspended? muted? silenced? + + // now we can return the masto representation of those accounts + mastoAccounts := []*apimodel.Account{} + for _, acc := range filteredAccounts { + mastoAccount, err := p.tc.AccountToMastoPublic(acc) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + mastoAccounts = append(mastoAccounts, mastoAccount) + } + + return mastoAccounts, nil +} diff --git a/internal/processing/synchronous/status/get.go b/internal/processing/synchronous/status/get.go @@ -0,0 +1,58 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Get(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + l := p.log.WithField("func", "StatusGet") + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + return mastoStatus, nil + +} diff --git a/internal/processing/synchronous/status/status.go b/internal/processing/synchronous/status/status.go @@ -0,0 +1,52 @@ +package status + +import ( + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Processor wraps a bunch of functions for processing statuses. +type Processor interface { + // Create processes the given form to create a new status, returning the api model representation of that status if it's OK. + Create(account *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) + // Delete processes the delete of a given status, returning the deleted status if the delete goes through. + Delete(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // Fave processes the faving of a given status, returning the updated status if the fave goes through. + Fave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // Boost processes the boost/reblog of a given status, returning the newly-created boost if all is well. + Boost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // BoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings. + BoostedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) + // FavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings. + FavedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) + // Get gets the given status, taking account of privacy settings and blocks etc. + Get(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // Unfave processes the unfaving of a given status, returning the updated status if the fave goes through. + Unfave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // Context returns the context (previous and following posts) from the given status ID + Context(account *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) +} + +type processor struct { + tc typeutils.TypeConverter + config *config.Config + db db.DB + fromClientAPI chan gtsmodel.FromClientAPI + log *logrus.Logger +} + +// New returns a new status processor. +func New(db db.DB, tc typeutils.TypeConverter, config *config.Config, fromClientAPI chan gtsmodel.FromClientAPI, log *logrus.Logger) Processor { + return &processor{ + tc: tc, + config: config, + db: db, + fromClientAPI: fromClientAPI, + log: log, + } +} diff --git a/internal/processing/synchronous/status/unfave.go b/internal/processing/synchronous/status/unfave.go @@ -0,0 +1,92 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Unfave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + l := p.log.WithField("func", "StatusUnfave") + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + } + + // check if we actually have a fave for this status + var toUnfave bool + + gtsFave := &gtsmodel.StatusFave{} + err = p.db.GetWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: account.ID}}, gtsFave) + if err == nil { + // we have a fave + toUnfave = true + } + if err != nil { + // something went wrong in the db finding the fave + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching existing fave from database: %s", err)) + } + // we just don't have a fave + toUnfave = false + } + + if toUnfave { + // we had a fave, so take some action to get rid of it + if err := p.db.DeleteWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: account.ID}}, gtsFave); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unfaveing status: %s", err)) + } + + // send it back to the processor for async processing + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsLike, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: gtsFave, + OriginAccount: account, + TargetAccount: targetAccount, + } + } + + // return the status (whatever its state) back to the caller + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + return mastoStatus, nil +} diff --git a/internal/processing/synchronous/status/util.go b/internal/processing/synchronous/status/util.go @@ -0,0 +1,269 @@ +package status + +import ( + "errors" + "fmt" + "strings" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { + // by default all flags are set to true + gtsAdvancedVis := &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + } + + var gtsBasicVis gtsmodel.Visibility + // Advanced takes priority if it's set. + // If it's not set, take whatever masto visibility is set. + // If *that's* not set either, then just take the account default. + // If that's also not set, take the default for the whole instance. + if form.VisibilityAdvanced != nil { + gtsBasicVis = gtsmodel.Visibility(*form.VisibilityAdvanced) + } else if form.Visibility != "" { + gtsBasicVis = p.tc.MastoVisToVis(form.Visibility) + } else if accountDefaultVis != "" { + gtsBasicVis = accountDefaultVis + } else { + gtsBasicVis = gtsmodel.VisibilityDefault + } + + switch gtsBasicVis { + case gtsmodel.VisibilityPublic: + // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out + break + case gtsmodel.VisibilityUnlocked: + // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them + if form.Federated != nil { + gtsAdvancedVis.Federated = *form.Federated + } + + if form.Boostable != nil { + gtsAdvancedVis.Boostable = *form.Boostable + } + + if form.Replyable != nil { + gtsAdvancedVis.Replyable = *form.Replyable + } + + if form.Likeable != nil { + gtsAdvancedVis.Likeable = *form.Likeable + } + + case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: + // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them + gtsAdvancedVis.Boostable = false + + if form.Federated != nil { + gtsAdvancedVis.Federated = *form.Federated + } + + if form.Replyable != nil { + gtsAdvancedVis.Replyable = *form.Replyable + } + + if form.Likeable != nil { + gtsAdvancedVis.Likeable = *form.Likeable + } + + case gtsmodel.VisibilityDirect: + // direct is pretty easy: there's only one possible setting so return it + gtsAdvancedVis.Federated = true + gtsAdvancedVis.Boostable = false + gtsAdvancedVis.Federated = true + gtsAdvancedVis.Likeable = true + } + + status.Visibility = gtsBasicVis + status.VisibilityAdvanced = gtsAdvancedVis + return nil +} + +func (p *processor) processReplyToID(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { + if form.InReplyToID == "" { + return nil + } + + // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted: + // + // 1. Does the replied status exist in the database? + // 2. Is the replied status marked as replyable? + // 3. Does a block exist between either the current account or the account that posted the status it's replying to? + // + // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. + repliedStatus := &gtsmodel.Status{} + repliedAccount := &gtsmodel.Account{} + // check replied status exists + is replyable + if err := p.db.GetByID(form.InReplyToID, repliedStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) + } + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + + if repliedStatus.VisibilityAdvanced != nil { + if !repliedStatus.VisibilityAdvanced.Replyable { + return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) + } + } + + // check replied account is known to us + if err := p.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) + } + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + // check if a block exists + if blocked, err := p.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + } else if blocked { + return fmt.Errorf("status with id %s not replyable", form.InReplyToID) + } + status.InReplyToID = repliedStatus.ID + status.InReplyToAccountID = repliedAccount.ID + + return nil +} + +func (p *processor) processMediaIDs(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { + if form.MediaIDs == nil { + return nil + } + + gtsMediaAttachments := []*gtsmodel.MediaAttachment{} + attachments := []string{} + for _, mediaID := range form.MediaIDs { + // check these attachments exist + a := &gtsmodel.MediaAttachment{} + if err := p.db.GetByID(mediaID, a); err != nil { + return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) + } + // check they belong to the requesting account id + if a.AccountID != thisAccountID { + return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) + } + // check they're not already used in a status + if a.StatusID != "" || a.ScheduledStatusID != "" { + return fmt.Errorf("media with id %s is already attached to a status", mediaID) + } + gtsMediaAttachments = append(gtsMediaAttachments, a) + attachments = append(attachments, a.ID) + } + status.GTSMediaAttachments = gtsMediaAttachments + status.Attachments = attachments + return nil +} + +func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { + if form.Language != "" { + status.Language = form.Language + } else { + status.Language = accountDefaultLanguage + } + if status.Language == "" { + return errors.New("no language given either in status create form or account default") + } + return nil +} + +func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + menchies := []string{} + gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentionsFromStatus(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating mentions from status: %s", err) + } + for _, menchie := range gtsMenchies { + menchieID, err := id.NewRandomULID() + if err != nil { + return err + } + menchie.ID = menchieID + + if err := p.db.Put(menchie); err != nil { + return fmt.Errorf("error putting mentions in db: %s", err) + } + menchies = append(menchies, menchie.ID) + } + // add full populated gts menchies to the status for passing them around conveniently + status.GTSMentions = gtsMenchies + // add just the ids of the mentioned accounts to the status for putting in the db + status.Mentions = menchies + return nil +} + +func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + tags := []string{} + gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtagsFromStatus(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating hashtags from status: %s", err) + } + for _, tag := range gtsTags { + if err := p.db.Upsert(tag, "name"); err != nil { + return fmt.Errorf("error putting tags in db: %s", err) + } + tags = append(tags, tag.ID) + } + // add full populated gts tags to the status for passing them around conveniently + status.GTSTags = gtsTags + // add just the ids of the used tags to the status for putting in the db + status.Tags = tags + return nil +} + +func (p *processor) processEmojis(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + emojis := []string{} + gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojisFromStatus(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating emojis from status: %s", err) + } + for _, e := range gtsEmojis { + emojis = append(emojis, e.ID) + } + // add full populated gts emojis to the status for passing them around conveniently + status.GTSEmojis = gtsEmojis + // add just the ids of the used emojis to the status for putting in the db + status.Emojis = emojis + return nil +} + +func (p *processor) processContent(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + if form.Status == "" { + status.Content = "" + return nil + } + + // surround the whole status in '<p>' + content := fmt.Sprintf(`<p>%s</p>`, form.Status) + + // format mentions nicely + for _, menchie := range status.GTSMentions { + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(menchie.TargetAccountID, targetAccount); err == nil { + mentionContent := fmt.Sprintf(`<span class="h-card"><a href="%s" class="u-url mention">@<span>%s</span></a></span>`, targetAccount.URL, targetAccount.Username) + content = strings.ReplaceAll(content, menchie.NameString, mentionContent) + } + } + + // format tags nicely + for _, tag := range status.GTSTags { + tagContent := fmt.Sprintf(`<a href="%s" class="mention hashtag" rel="tag">#<span>%s</span></a>`, tag.URL, tag.Name) + content = strings.ReplaceAll(content, fmt.Sprintf("#%s", tag.Name), tagContent) + } + + // replace newlines with breaks + content = strings.ReplaceAll(content, "\n", "<br />") + + status.Content = content + return nil +} diff --git a/internal/processing/timeline.go b/internal/processing/timeline.go @@ -20,45 +20,70 @@ package processing import ( "fmt" + "net/url" + "sync" + "github.com/sirupsen/logrus" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *processor) HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]apimodel.Status, ErrorWithCode) { - statuses, err := p.db.GetHomeTimelineForAccount(authed.Account.ID, maxID, sinceID, minID, limit, local) - if err != nil { - return nil, NewErrorInternalError(err) +func (p *processor) HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { + resp := &apimodel.StatusTimelineResponse{ + Statuses: []*apimodel.Status{}, } - s, err := p.filterStatuses(authed, statuses) + apiStatuses, err := p.timelineManager.HomeTimeline(authed.Account.ID, maxID, sinceID, minID, limit, local) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } + resp.Statuses = apiStatuses + + // prepare the next and previous links + if len(apiStatuses) != 0 { + nextLink := &url.URL{ + Scheme: p.config.Protocol, + Host: p.config.Host, + Path: "/api/v1/timelines/home", + RawPath: url.PathEscape("api/v1/timelines/home"), + RawQuery: fmt.Sprintf("limit=%d&max_id=%s", limit, apiStatuses[len(apiStatuses)-1].ID), + } + next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String()) - return s, nil + prevLink := &url.URL{ + Scheme: p.config.Protocol, + Host: p.config.Host, + Path: "/api/v1/timelines/home", + RawQuery: fmt.Sprintf("limit=%d&min_id=%s", limit, apiStatuses[0].ID), + } + prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLink.String()) + resp.LinkHeader = fmt.Sprintf("%s, %s", next, prev) + } + + return resp, nil } -func (p *processor) PublicTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]apimodel.Status, ErrorWithCode) { +func (p *processor) PublicTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, gtserror.WithCode) { statuses, err := p.db.GetPublicTimelineForAccount(authed.Account.ID, maxID, sinceID, minID, limit, local) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } s, err := p.filterStatuses(authed, statuses) if err != nil { - return nil, NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } return s, nil } -func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Status) ([]apimodel.Status, error) { +func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) { l := p.log.WithField("func", "filterStatuses") - apiStatuses := []apimodel.Status{} + apiStatuses := []*apimodel.Status{} for _, s := range statuses { targetAccount := &gtsmodel.Account{} if err := p.db.GetByID(s.AccountID, targetAccount); err != nil { @@ -66,7 +91,7 @@ func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Stat l.Debugf("skipping status %s because account %s can't be found in the db", s.ID, s.AccountID) continue } - return nil, NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error getting status author: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error getting status author: %s", err)) } relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(s) @@ -75,9 +100,9 @@ func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Stat continue } - visible, err := p.db.StatusVisible(s, targetAccount, authed.Account, relevantAccounts) + visible, err := p.db.StatusVisible(s, authed.Account, relevantAccounts) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error checking status visibility: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error checking status visibility: %s", err)) } if !visible { continue @@ -91,7 +116,7 @@ func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Stat l.Debugf("skipping status %s because status %s can't be found in the db", s.ID, s.BoostOfID) continue } - return nil, NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error getting boosted status: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error getting boosted status: %s", err)) } boostedRelevantAccounts, err := p.db.PullRelevantAccountsFromStatus(bs) if err != nil { @@ -99,9 +124,9 @@ func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Stat continue } - boostedVisible, err := p.db.StatusVisible(bs, relevantAccounts.BoostedAccount, authed.Account, boostedRelevantAccounts) + boostedVisible, err := p.db.StatusVisible(bs, authed.Account, boostedRelevantAccounts) if err != nil { - return nil, NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error checking boosted status visibility: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error checking boosted status visibility: %s", err)) } if boostedVisible { @@ -115,8 +140,113 @@ func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Stat continue } - apiStatuses = append(apiStatuses, *apiStatus) + apiStatuses = append(apiStatuses, apiStatus) } return apiStatuses, nil } + +func (p *processor) initTimelines() error { + // get all local accounts (ie., domain = nil) that aren't suspended (suspended_at = nil) + localAccounts := []*gtsmodel.Account{} + where := []db.Where{ + { + Key: "domain", Value: nil, + }, + { + Key: "suspended_at", Value: nil, + }, + } + if err := p.db.GetWhere(where, &localAccounts); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil + } + return fmt.Errorf("initTimelines: db error initializing timelines: %s", err) + } + + // we want to wait until all timelines are populated so created a waitgroup here + wg := &sync.WaitGroup{} + wg.Add(len(localAccounts)) + + for _, localAccount := range localAccounts { + // to save time we can populate the timelines asynchronously + // this will go heavy on the database, but since we're not actually serving yet it doesn't really matter + go p.initTimelineFor(localAccount, wg) + } + + // wait for all timelines to be populated before we exit + wg.Wait() + return nil +} + +func (p *processor) initTimelineFor(account *gtsmodel.Account, wg *sync.WaitGroup) { + defer wg.Done() + + l := p.log.WithFields(logrus.Fields{ + "func": "initTimelineFor", + "accountID": account.ID, + }) + + desiredIndexLength := p.timelineManager.GetDesiredIndexLength() + + statuses, err := p.db.GetStatusesWhereFollowing(account.ID, "", "", "", desiredIndexLength, false) + if err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + l.Error(fmt.Errorf("initTimelineFor: error getting statuses: %s", err)) + } + return + } + p.indexAndIngest(statuses, account, desiredIndexLength) + + lengthNow := p.timelineManager.GetIndexedLength(account.ID) + if lengthNow < desiredIndexLength { + // try and get more posts from the last ID onwards + rearmostStatusID, err := p.timelineManager.GetOldestIndexedID(account.ID) + if err != nil { + l.Error(fmt.Errorf("initTimelineFor: error getting id of rearmost status: %s", err)) + return + } + + if rearmostStatusID != "" { + moreStatuses, err := p.db.GetStatusesWhereFollowing(account.ID, rearmostStatusID, "", "", desiredIndexLength/2, false) + if err != nil { + l.Error(fmt.Errorf("initTimelineFor: error getting more statuses: %s", err)) + return + } + p.indexAndIngest(moreStatuses, account, desiredIndexLength) + } + } + + l.Debugf("prepared timeline of length %d for account %s", lengthNow, account.ID) +} + +func (p *processor) indexAndIngest(statuses []*gtsmodel.Status, timelineAccount *gtsmodel.Account, desiredIndexLength int) { + l := p.log.WithFields(logrus.Fields{ + "func": "indexAndIngest", + "accountID": timelineAccount.ID, + }) + + for _, s := range statuses { + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(s) + if err != nil { + l.Error(fmt.Errorf("initTimelineFor: error getting relevant accounts from status %s: %s", s.ID, err)) + continue + } + visible, err := p.db.StatusVisible(s, timelineAccount, relevantAccounts) + if err != nil { + l.Error(fmt.Errorf("initTimelineFor: error checking visibility of status %s: %s", s.ID, err)) + continue + } + if visible { + if err := p.timelineManager.Ingest(s, timelineAccount.ID); err != nil { + l.Error(fmt.Errorf("initTimelineFor: error ingesting status %s: %s", s.ID, err)) + continue + } + + // check if we have enough posts now and return if we do + if p.timelineManager.GetIndexedLength(timelineAccount.ID) >= desiredIndexLength { + return + } + } + } +} diff --git a/internal/processing/util.go b/internal/processing/util.go @@ -25,233 +25,11 @@ import ( "io" "mime/multipart" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/transport" - "github.com/superseriousbusiness/gotosocial/internal/util" ) -func (p *processor) processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { - // by default all flags are set to true - gtsAdvancedVis := &gtsmodel.VisibilityAdvanced{ - Federated: true, - Boostable: true, - Replyable: true, - Likeable: true, - } - - var gtsBasicVis gtsmodel.Visibility - // Advanced takes priority if it's set. - // If it's not set, take whatever masto visibility is set. - // If *that's* not set either, then just take the account default. - // If that's also not set, take the default for the whole instance. - if form.VisibilityAdvanced != nil { - gtsBasicVis = gtsmodel.Visibility(*form.VisibilityAdvanced) - } else if form.Visibility != "" { - gtsBasicVis = p.tc.MastoVisToVis(form.Visibility) - } else if accountDefaultVis != "" { - gtsBasicVis = accountDefaultVis - } else { - gtsBasicVis = gtsmodel.VisibilityDefault - } - - switch gtsBasicVis { - case gtsmodel.VisibilityPublic: - // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out - break - case gtsmodel.VisibilityUnlocked: - // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them - if form.Federated != nil { - gtsAdvancedVis.Federated = *form.Federated - } - - if form.Boostable != nil { - gtsAdvancedVis.Boostable = *form.Boostable - } - - if form.Replyable != nil { - gtsAdvancedVis.Replyable = *form.Replyable - } - - if form.Likeable != nil { - gtsAdvancedVis.Likeable = *form.Likeable - } - - case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: - // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them - gtsAdvancedVis.Boostable = false - - if form.Federated != nil { - gtsAdvancedVis.Federated = *form.Federated - } - - if form.Replyable != nil { - gtsAdvancedVis.Replyable = *form.Replyable - } - - if form.Likeable != nil { - gtsAdvancedVis.Likeable = *form.Likeable - } - - case gtsmodel.VisibilityDirect: - // direct is pretty easy: there's only one possible setting so return it - gtsAdvancedVis.Federated = true - gtsAdvancedVis.Boostable = false - gtsAdvancedVis.Federated = true - gtsAdvancedVis.Likeable = true - } - - status.Visibility = gtsBasicVis - status.VisibilityAdvanced = gtsAdvancedVis - return nil -} - -func (p *processor) processReplyToID(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { - if form.InReplyToID == "" { - return nil - } - - // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted: - // - // 1. Does the replied status exist in the database? - // 2. Is the replied status marked as replyable? - // 3. Does a block exist between either the current account or the account that posted the status it's replying to? - // - // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. - repliedStatus := &gtsmodel.Status{} - repliedAccount := &gtsmodel.Account{} - // check replied status exists + is replyable - if err := p.db.GetByID(form.InReplyToID, repliedStatus); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) - } - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - - if repliedStatus.VisibilityAdvanced != nil { - if !repliedStatus.VisibilityAdvanced.Replyable { - return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) - } - } - - // check replied account is known to us - if err := p.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) - } - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - // check if a block exists - if blocked, err := p.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - } else if blocked { - return fmt.Errorf("status with id %s not replyable", form.InReplyToID) - } - status.InReplyToID = repliedStatus.ID - status.InReplyToAccountID = repliedAccount.ID - - return nil -} - -func (p *processor) processMediaIDs(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { - if form.MediaIDs == nil { - return nil - } - - gtsMediaAttachments := []*gtsmodel.MediaAttachment{} - attachments := []string{} - for _, mediaID := range form.MediaIDs { - // check these attachments exist - a := &gtsmodel.MediaAttachment{} - if err := p.db.GetByID(mediaID, a); err != nil { - return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) - } - // check they belong to the requesting account id - if a.AccountID != thisAccountID { - return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) - } - // check they're not already used in a status - if a.StatusID != "" || a.ScheduledStatusID != "" { - return fmt.Errorf("media with id %s is already attached to a status", mediaID) - } - gtsMediaAttachments = append(gtsMediaAttachments, a) - attachments = append(attachments, a.ID) - } - status.GTSMediaAttachments = gtsMediaAttachments - status.Attachments = attachments - return nil -} - -func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { - if form.Language != "" { - status.Language = form.Language - } else { - status.Language = accountDefaultLanguage - } - if status.Language == "" { - return errors.New("no language given either in status create form or account default") - } - return nil -} - -func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - menchies := []string{} - gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentionsFromStatus(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating mentions from status: %s", err) - } - for _, menchie := range gtsMenchies { - if err := p.db.Put(menchie); err != nil { - return fmt.Errorf("error putting mentions in db: %s", err) - } - menchies = append(menchies, menchie.ID) - } - // add full populated gts menchies to the status for passing them around conveniently - status.GTSMentions = gtsMenchies - // add just the ids of the mentioned accounts to the status for putting in the db - status.Mentions = menchies - return nil -} - -func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - tags := []string{} - gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtagsFromStatus(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating hashtags from status: %s", err) - } - for _, tag := range gtsTags { - if err := p.db.Upsert(tag, "name"); err != nil { - return fmt.Errorf("error putting tags in db: %s", err) - } - tags = append(tags, tag.ID) - } - // add full populated gts tags to the status for passing them around conveniently - status.GTSTags = gtsTags - // add just the ids of the used tags to the status for putting in the db - status.Tags = tags - return nil -} - -func (p *processor) processEmojis(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - emojis := []string{} - gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojisFromStatus(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating emojis from status: %s", err) - } - for _, e := range gtsEmojis { - emojis = append(emojis, e.ID) - } - // add full populated gts emojis to the status for passing them around conveniently - status.GTSEmojis = gtsEmojis - // add just the ids of the used emojis to the status for putting in the db - status.Emojis = emojis - return nil -} - /* HELPER FUNCTIONS */ diff --git a/internal/router/router.go b/internal/router/router.go @@ -125,11 +125,13 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) { // create the actual engine here -- this is the core request routing handler for gts engine := gin.Default() engine.Use(cors.New(cors.Config{ - AllowAllOrigins: true, - AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, - AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, - AllowCredentials: false, - MaxAge: 12 * time.Hour, + AllowAllOrigins: true, + AllowBrowserExtensions: true, + AllowMethods: []string{"POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + AllowWebSockets: true, + ExposeHeaders: []string{"Link", "X-RateLimit-Reset", "X-RateLimit-Limit", " X-RateLimit-Remaining", "X-Request-Id"}, + MaxAge: 2 * time.Minute, })) engine.MaxMultipartMemory = 8 << 20 // 8 MiB diff --git a/internal/timeline/get.go b/internal/timeline/get.go @@ -0,0 +1,309 @@ +package timeline + +import ( + "container/list" + "errors" + "fmt" + + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +) + +func (t *timeline) Get(amount int, maxID string, sinceID string, minID string) ([]*apimodel.Status, error) { + l := t.log.WithFields(logrus.Fields{ + "func": "Get", + "accountID": t.accountID, + }) + + var statuses []*apimodel.Status + var err error + + // no params are defined to just fetch from the top + if maxID == "" && sinceID == "" && minID == "" { + statuses, err = t.GetXFromTop(amount) + // aysnchronously prepare the next predicted query so it's ready when the user asks for it + if len(statuses) != 0 { + nextMaxID := statuses[len(statuses)-1].ID + go func() { + if err := t.prepareNextQuery(amount, nextMaxID, "", ""); err != nil { + l.Errorf("error preparing next query: %s", err) + } + }() + } + } + + // maxID is defined but sinceID isn't so take from behind + if maxID != "" && sinceID == "" { + statuses, err = t.GetXBehindID(amount, maxID) + // aysnchronously prepare the next predicted query so it's ready when the user asks for it + if len(statuses) != 0 { + nextMaxID := statuses[len(statuses)-1].ID + go func() { + if err := t.prepareNextQuery(amount, nextMaxID, "", ""); err != nil { + l.Errorf("error preparing next query: %s", err) + } + }() + } + } + + // maxID is defined and sinceID || minID are as well, so take a slice between them + if maxID != "" && sinceID != "" { + statuses, err = t.GetXBetweenID(amount, maxID, minID) + } + if maxID != "" && minID != "" { + statuses, err = t.GetXBetweenID(amount, maxID, minID) + } + + // maxID isn't defined, but sinceID || minID are, so take x before + if maxID == "" && sinceID != "" { + statuses, err = t.GetXBeforeID(amount, sinceID, true) + } + if maxID == "" && minID != "" { + statuses, err = t.GetXBeforeID(amount, minID, true) + } + + return statuses, err +} + +func (t *timeline) GetXFromTop(amount int) ([]*apimodel.Status, error) { + // make a slice of statuses with the length we need to return + statuses := make([]*apimodel.Status, 0, amount) + + if t.preparedPosts.data == nil { + t.preparedPosts.data = &list.List{} + } + + // make sure we have enough posts prepared to return + if t.preparedPosts.data.Len() < amount { + if err := t.PrepareFromTop(amount); err != nil { + return nil, err + } + } + + // work through the prepared posts from the top and return + var served int + for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return nil, errors.New("GetXFromTop: could not parse e as a preparedPostsEntry") + } + statuses = append(statuses, entry.prepared) + served = served + 1 + if served >= amount { + break + } + } + + return statuses, nil +} + +func (t *timeline) GetXBehindID(amount int, behindID string) ([]*apimodel.Status, error) { + // make a slice of statuses with the length we need to return + statuses := make([]*apimodel.Status, 0, amount) + + if t.preparedPosts.data == nil { + t.preparedPosts.data = &list.List{} + } + + // iterate through the modified list until we hit the mark we're looking for + var position int + var behindIDMark *list.Element + +findMarkLoop: + for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { + position = position + 1 + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry") + } + + if entry.statusID == behindID { + behindIDMark = e + break findMarkLoop + } + } + + // we didn't find it, so we need to make sure it's indexed and prepared and then try again + if behindIDMark == nil { + if err := t.IndexBehind(behindID, amount); err != nil { + return nil, fmt.Errorf("GetXBehindID: error indexing behind and including ID %s", behindID) + } + if err := t.PrepareBehind(behindID, amount); err != nil { + return nil, fmt.Errorf("GetXBehindID: error preparing behind and including ID %s", behindID) + } + oldestID, err := t.OldestPreparedPostID() + if err != nil { + return nil, err + } + if oldestID == "" || oldestID == behindID { + // there is no oldest prepared post, or the oldest prepared post is still the post we're looking for entries after + // this means we should just return the empty statuses slice since we don't have any more posts to offer + return statuses, nil + } + return t.GetXBehindID(amount, behindID) + } + + // make sure we have enough posts prepared behind it to return what we're being asked for + if t.preparedPosts.data.Len() < amount+position { + if err := t.PrepareBehind(behindID, amount); err != nil { + return nil, err + } + } + + // start serving from the entry right after the mark + var served int +serveloop: + for e := behindIDMark.Next(); e != nil; e = e.Next() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry") + } + + // serve up to the amount requested + statuses = append(statuses, entry.prepared) + served = served + 1 + if served >= amount { + break serveloop + } + } + + return statuses, nil +} + +func (t *timeline) GetXBeforeID(amount int, beforeID string, startFromTop bool) ([]*apimodel.Status, error) { + // make a slice of statuses with the length we need to return + statuses := make([]*apimodel.Status, 0, amount) + + if t.preparedPosts.data == nil { + t.preparedPosts.data = &list.List{} + } + + // iterate through the modified list until we hit the mark we're looking for + var beforeIDMark *list.Element +findMarkLoop: + for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") + } + + if entry.statusID == beforeID { + beforeIDMark = e + break findMarkLoop + } + } + + // we didn't find it, so we need to make sure it's indexed and prepared and then try again + if beforeIDMark == nil { + if err := t.IndexBefore(beforeID, true, amount); err != nil { + return nil, fmt.Errorf("GetXBeforeID: error indexing before and including ID %s", beforeID) + } + if err := t.PrepareBefore(beforeID, true, amount); err != nil { + return nil, fmt.Errorf("GetXBeforeID: error preparing before and including ID %s", beforeID) + } + return t.GetXBeforeID(amount, beforeID, startFromTop) + } + + var served int + + if startFromTop { + // start serving from the front/top and keep going until we hit mark or get x amount statuses + serveloopFromTop: + for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") + } + + if entry.statusID == beforeID { + break serveloopFromTop + } + + // serve up to the amount requested + statuses = append(statuses, entry.prepared) + served = served + 1 + if served >= amount { + break serveloopFromTop + } + } + } else if !startFromTop { + // start serving from the entry right before the mark + serveloopFromBottom: + for e := beforeIDMark.Prev(); e != nil; e = e.Prev() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") + } + + // serve up to the amount requested + statuses = append(statuses, entry.prepared) + served = served + 1 + if served >= amount { + break serveloopFromBottom + } + } + } + + return statuses, nil +} + +func (t *timeline) GetXBetweenID(amount int, behindID string, beforeID string) ([]*apimodel.Status, error) { + // make a slice of statuses with the length we need to return + statuses := make([]*apimodel.Status, 0, amount) + + if t.preparedPosts.data == nil { + t.preparedPosts.data = &list.List{} + } + + // iterate through the modified list until we hit the mark we're looking for + var position int + var behindIDMark *list.Element +findMarkLoop: + for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { + position = position + 1 + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return nil, errors.New("GetXBetweenID: could not parse e as a preparedPostsEntry") + } + + if entry.statusID == behindID { + behindIDMark = e + break findMarkLoop + } + } + + // we didn't find it + if behindIDMark == nil { + return nil, fmt.Errorf("GetXBetweenID: couldn't find status with ID %s", behindID) + } + + // make sure we have enough posts prepared behind it to return what we're being asked for + if t.preparedPosts.data.Len() < amount+position { + if err := t.PrepareBehind(behindID, amount); err != nil { + return nil, err + } + } + + // start serving from the entry right after the mark + var served int +serveloop: + for e := behindIDMark.Next(); e != nil; e = e.Next() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return nil, errors.New("GetXBetweenID: could not parse e as a preparedPostsEntry") + } + + if entry.statusID == beforeID { + break serveloop + } + + // serve up to the amount requested + statuses = append(statuses, entry.prepared) + served = served + 1 + if served >= amount { + break serveloop + } + } + + return statuses, nil +} diff --git a/internal/timeline/index.go b/internal/timeline/index.go @@ -0,0 +1,143 @@ +package timeline + +import ( + "errors" + "fmt" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (t *timeline) IndexBefore(statusID string, include bool, amount int) error { + // filtered := []*gtsmodel.Status{} + // offsetStatus := statusID + + // grabloop: + // for len(filtered) < amount { + // statuses, err := t.db.GetStatusesWhereFollowing(t.accountID, amount, offsetStatus, include, true) + // if err != nil { + // if _, ok := err.(db.ErrNoEntries); !ok { + // return fmt.Errorf("IndexBeforeAndIncluding: error getting statuses from db: %s", err) + // } + // break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail + // } + + // for _, s := range statuses { + // relevantAccounts, err := t.db.PullRelevantAccountsFromStatus(s) + // if err != nil { + // continue + // } + // visible, err := t.db.StatusVisible(s, t.account, relevantAccounts) + // if err != nil { + // continue + // } + // if visible { + // filtered = append(filtered, s) + // } + // offsetStatus = s.ID + // } + // } + + // for _, s := range filtered { + // if err := t.IndexOne(s.CreatedAt, s.ID); err != nil { + // return fmt.Errorf("IndexBeforeAndIncluding: error indexing status with id %s: %s", s.ID, err) + // } + // } + + return nil +} + +func (t *timeline) IndexBehind(statusID string, amount int) error { + filtered := []*gtsmodel.Status{} + offsetStatus := statusID + +grabloop: + for len(filtered) < amount { + statuses, err := t.db.GetStatusesWhereFollowing(t.accountID, offsetStatus, "", "", amount, false) + if err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail + } + return fmt.Errorf("IndexBehindAndIncluding: error getting statuses from db: %s", err) + } + + for _, s := range statuses { + relevantAccounts, err := t.db.PullRelevantAccountsFromStatus(s) + if err != nil { + continue + } + visible, err := t.db.StatusVisible(s, t.account, relevantAccounts) + if err != nil { + continue + } + if visible { + filtered = append(filtered, s) + } + offsetStatus = s.ID + } + } + + for _, s := range filtered { + if err := t.IndexOne(s.CreatedAt, s.ID); err != nil { + return fmt.Errorf("IndexBehindAndIncluding: error indexing status with id %s: %s", s.ID, err) + } + } + + return nil +} + +func (t *timeline) IndexOneByID(statusID string) error { + return nil +} + +func (t *timeline) IndexOne(statusCreatedAt time.Time, statusID string) error { + t.Lock() + defer t.Unlock() + + postIndexEntry := &postIndexEntry{ + statusID: statusID, + } + + return t.postIndex.insertIndexed(postIndexEntry) +} + +func (t *timeline) IndexAndPrepareOne(statusCreatedAt time.Time, statusID string) error { + t.Lock() + defer t.Unlock() + + postIndexEntry := &postIndexEntry{ + statusID: statusID, + } + + if err := t.postIndex.insertIndexed(postIndexEntry); err != nil { + return fmt.Errorf("IndexAndPrepareOne: error inserting indexed: %s", err) + } + + if err := t.prepare(statusID); err != nil { + return fmt.Errorf("IndexAndPrepareOne: error preparing: %s", err) + } + + return nil +} + +func (t *timeline) OldestIndexedPostID() (string, error) { + var id string + if t.postIndex == nil || t.postIndex.data == nil { + // return an empty string if postindex hasn't been initialized yet + return id, nil + } + + e := t.postIndex.data.Back() + + if e == nil { + // return an empty string if there's no back entry (ie., the index list hasn't been initialized yet) + return id, nil + } + + entry, ok := e.Value.(*postIndexEntry) + if !ok { + return id, errors.New("OldestIndexedPostID: could not parse e as a postIndexEntry") + } + return entry.statusID, nil +} diff --git a/internal/timeline/manager.go b/internal/timeline/manager.go @@ -0,0 +1,217 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package timeline + +import ( + "fmt" + "strings" + "sync" + + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +const ( + desiredPostIndexLength = 400 +) + +// Manager abstracts functions for creating timelines for multiple accounts, and adding, removing, and fetching entries from those timelines. +// +// By the time a status hits the manager interface, it should already have been filtered and it should be established that the status indeed +// belongs in the home timeline of the given account ID. +// +// The manager makes a distinction between *indexed* posts and *prepared* posts. +// +// Indexed posts consist of just that post's ID (in the database) and the time it was created. An indexed post takes up very little memory, so +// it's not a huge priority to keep trimming the indexed posts list. +// +// Prepared posts consist of the post's database ID, the time it was created, AND the apimodel representation of that post, for quick serialization. +// Prepared posts of course take up more memory than indexed posts, so they should be regularly pruned if they're not being actively served. +type Manager interface { + // Ingest takes one status and indexes it into the timeline for the given account ID. + // + // It should already be established before calling this function that the status/post actually belongs in the timeline! + Ingest(status *gtsmodel.Status, timelineAccountID string) error + // IngestAndPrepare takes one status and indexes it into the timeline for the given account ID, and then immediately prepares it for serving. + // This is useful in cases where we know the status will need to be shown at the top of a user's timeline immediately (eg., a new status is created). + // + // It should already be established before calling this function that the status/post actually belongs in the timeline! + IngestAndPrepare(status *gtsmodel.Status, timelineAccountID string) error + // HomeTimeline returns limit n amount of entries from the home timeline of the given account ID, in descending chronological order. + // If maxID is provided, it will return entries from that maxID onwards, inclusive. + HomeTimeline(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, error) + // GetIndexedLength returns the amount of posts/statuses that have been *indexed* for the given account ID. + GetIndexedLength(timelineAccountID string) int + // GetDesiredIndexLength returns the amount of posts that we, ideally, index for each user. + GetDesiredIndexLength() int + // GetOldestIndexedID returns the status ID for the oldest post that we have indexed for the given account. + GetOldestIndexedID(timelineAccountID string) (string, error) + // PrepareXFromTop prepares limit n amount of posts, based on their indexed representations, from the top of the index. + PrepareXFromTop(timelineAccountID string, limit int) error + // WipeStatusFromTimeline completely removes a status and from the index and prepared posts of the given account ID + // + // The returned int indicates how many entries were removed. + WipeStatusFromTimeline(timelineAccountID string, statusID string) (int, error) + // WipeStatusFromAllTimelines removes the status from the index and prepared posts of all timelines + WipeStatusFromAllTimelines(statusID string) error +} + +// NewManager returns a new timeline manager with the given database, typeconverter, config, and log. +func NewManager(db db.DB, tc typeutils.TypeConverter, config *config.Config, log *logrus.Logger) Manager { + return &manager{ + accountTimelines: sync.Map{}, + db: db, + tc: tc, + config: config, + log: log, + } +} + +type manager struct { + accountTimelines sync.Map + db db.DB + tc typeutils.TypeConverter + config *config.Config + log *logrus.Logger +} + +func (m *manager) Ingest(status *gtsmodel.Status, timelineAccountID string) error { + l := m.log.WithFields(logrus.Fields{ + "func": "Ingest", + "timelineAccountID": timelineAccountID, + "statusID": status.ID, + }) + + t := m.getOrCreateTimeline(timelineAccountID) + + l.Trace("ingesting status") + return t.IndexOne(status.CreatedAt, status.ID) +} + +func (m *manager) IngestAndPrepare(status *gtsmodel.Status, timelineAccountID string) error { + l := m.log.WithFields(logrus.Fields{ + "func": "IngestAndPrepare", + "timelineAccountID": timelineAccountID, + "statusID": status.ID, + }) + + t := m.getOrCreateTimeline(timelineAccountID) + + l.Trace("ingesting status") + return t.IndexAndPrepareOne(status.CreatedAt, status.ID) +} + +func (m *manager) Remove(statusID string, timelineAccountID string) (int, error) { + l := m.log.WithFields(logrus.Fields{ + "func": "Remove", + "timelineAccountID": timelineAccountID, + "statusID": statusID, + }) + + t := m.getOrCreateTimeline(timelineAccountID) + + l.Trace("removing status") + return t.Remove(statusID) +} + +func (m *manager) HomeTimeline(timelineAccountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, error) { + l := m.log.WithFields(logrus.Fields{ + "func": "HomeTimelineGet", + "timelineAccountID": timelineAccountID, + }) + + t := m.getOrCreateTimeline(timelineAccountID) + + statuses, err := t.Get(limit, maxID, sinceID, minID) + if err != nil { + l.Errorf("error getting statuses: %s", err) + } + return statuses, nil +} + +func (m *manager) GetIndexedLength(timelineAccountID string) int { + t := m.getOrCreateTimeline(timelineAccountID) + + return t.PostIndexLength() +} + +func (m *manager) GetDesiredIndexLength() int { + return desiredPostIndexLength +} + +func (m *manager) GetOldestIndexedID(timelineAccountID string) (string, error) { + t := m.getOrCreateTimeline(timelineAccountID) + + return t.OldestIndexedPostID() +} + +func (m *manager) PrepareXFromTop(timelineAccountID string, limit int) error { + t := m.getOrCreateTimeline(timelineAccountID) + + return t.PrepareFromTop(limit) +} + +func (m *manager) WipeStatusFromTimeline(timelineAccountID string, statusID string) (int, error) { + t := m.getOrCreateTimeline(timelineAccountID) + + return t.Remove(statusID) +} + +func (m *manager) WipeStatusFromAllTimelines(statusID string) error { + errors := []string{} + m.accountTimelines.Range(func(k interface{}, i interface{}) bool { + t, ok := i.(Timeline) + if !ok { + panic("couldn't parse entry as Timeline, this should never happen so panic") + } + + if _, err := t.Remove(statusID); err != nil { + errors = append(errors, err.Error()) + } + + return false + }) + + var err error + if len(errors) > 0 { + err = fmt.Errorf("one or more errors removing status %s from all timelines: %s", statusID, strings.Join(errors, ";")) + } + + return err +} + +func (m *manager) getOrCreateTimeline(timelineAccountID string) Timeline { + var t Timeline + i, ok := m.accountTimelines.Load(timelineAccountID) + if !ok { + t = NewTimeline(timelineAccountID, m.db, m.tc, m.log) + m.accountTimelines.Store(timelineAccountID, t) + } else { + t, ok = i.(Timeline) + if !ok { + panic("couldn't parse entry as Timeline, this should never happen so panic") + } + } + + return t +} diff --git a/internal/timeline/postindex.go b/internal/timeline/postindex.go @@ -0,0 +1,57 @@ +package timeline + +import ( + "container/list" + "errors" +) + +type postIndex struct { + data *list.List +} + +type postIndexEntry struct { + statusID string +} + +func (p *postIndex) insertIndexed(i *postIndexEntry) error { + if p.data == nil { + p.data = &list.List{} + } + + // if we have no entries yet, this is both the newest and oldest entry, so just put it in the front + if p.data.Len() == 0 { + p.data.PushFront(i) + return nil + } + + var insertMark *list.Element + // We need to iterate through the index to make sure we put this post in the appropriate place according to when it was created. + // We also need to make sure we're not inserting a duplicate post -- this can happen sometimes and it's not nice UX (*shudder*). + for e := p.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*postIndexEntry) + if !ok { + return errors.New("index: could not parse e as a postIndexEntry") + } + + // if the post to index is newer than e, insert it before e in the list + if insertMark == nil { + if i.statusID > entry.statusID { + insertMark = e + } + } + + // make sure we don't insert a duplicate + if entry.statusID == i.statusID { + return nil + } + } + + if insertMark != nil { + p.data.InsertBefore(i, insertMark) + return nil + } + + // if we reach this point it's the oldest post we've seen so put it at the back + p.data.PushBack(i) + return nil +} diff --git a/internal/timeline/prepare.go b/internal/timeline/prepare.go @@ -0,0 +1,215 @@ +package timeline + +import ( + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (t *timeline) prepareNextQuery(amount int, maxID string, sinceID string, minID string) error { + var err error + + // maxID is defined but sinceID isn't so take from behind + if maxID != "" && sinceID == "" { + err = t.PrepareBehind(maxID, amount) + } + + // maxID isn't defined, but sinceID || minID are, so take x before + if maxID == "" && sinceID != "" { + err = t.PrepareBefore(sinceID, false, amount) + } + if maxID == "" && minID != "" { + err = t.PrepareBefore(minID, false, amount) + } + + return err +} + +func (t *timeline) PrepareBehind(statusID string, amount int) error { + t.Lock() + defer t.Unlock() + + var prepared int + var preparing bool +prepareloop: + for e := t.postIndex.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*postIndexEntry) + if !ok { + return errors.New("PrepareBehind: could not parse e as a postIndexEntry") + } + + if !preparing { + // we haven't hit the position we need to prepare from yet + if entry.statusID == statusID { + preparing = true + } + } + + if preparing { + if err := t.prepare(entry.statusID); err != nil { + // there's been an error + if _, ok := err.(db.ErrNoEntries); !ok { + // it's a real error + return fmt.Errorf("PrepareBehind: error preparing status with id %s: %s", entry.statusID, err) + } + // the status just doesn't exist (anymore) so continue to the next one + continue + } + if prepared == amount { + // we're done + break prepareloop + } + prepared = prepared + 1 + } + } + + return nil +} + +func (t *timeline) PrepareBefore(statusID string, include bool, amount int) error { + t.Lock() + defer t.Unlock() + + var prepared int + var preparing bool +prepareloop: + for e := t.postIndex.data.Back(); e != nil; e = e.Prev() { + entry, ok := e.Value.(*postIndexEntry) + if !ok { + return errors.New("PrepareBefore: could not parse e as a postIndexEntry") + } + + if !preparing { + // we haven't hit the position we need to prepare from yet + if entry.statusID == statusID { + preparing = true + if !include { + continue + } + } + } + + if preparing { + if err := t.prepare(entry.statusID); err != nil { + // there's been an error + if _, ok := err.(db.ErrNoEntries); !ok { + // it's a real error + return fmt.Errorf("PrepareBefore: error preparing status with id %s: %s", entry.statusID, err) + } + // the status just doesn't exist (anymore) so continue to the next one + continue + } + if prepared == amount { + // we're done + break prepareloop + } + prepared = prepared + 1 + } + } + + return nil +} + +func (t *timeline) PrepareFromTop(amount int) error { + t.Lock() + defer t.Unlock() + + t.preparedPosts.data.Init() + + var prepared int +prepareloop: + for e := t.postIndex.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*postIndexEntry) + if !ok { + return errors.New("PrepareFromTop: could not parse e as a postIndexEntry") + } + + if err := t.prepare(entry.statusID); err != nil { + // there's been an error + if _, ok := err.(db.ErrNoEntries); !ok { + // it's a real error + return fmt.Errorf("PrepareFromTop: error preparing status with id %s: %s", entry.statusID, err) + } + // the status just doesn't exist (anymore) so continue to the next one + continue + } + + prepared = prepared + 1 + if prepared == amount { + // we're done + break prepareloop + } + } + + return nil +} + +func (t *timeline) prepare(statusID string) error { + + // start by getting the status out of the database according to its indexed ID + gtsStatus := &gtsmodel.Status{} + if err := t.db.GetByID(statusID, gtsStatus); err != nil { + return err + } + + // if the account pointer hasn't been set on this timeline already, set it lazily here + if t.account == nil { + timelineOwnerAccount := &gtsmodel.Account{} + if err := t.db.GetByID(t.accountID, timelineOwnerAccount); err != nil { + return err + } + t.account = timelineOwnerAccount + } + + // to convert the status we need relevant accounts from it, so pull them out here + relevantAccounts, err := t.db.PullRelevantAccountsFromStatus(gtsStatus) + if err != nil { + return err + } + + // check if this is a boost... + var reblogOfStatus *gtsmodel.Status + if gtsStatus.BoostOfID != "" { + s := &gtsmodel.Status{} + if err := t.db.GetByID(gtsStatus.BoostOfID, s); err != nil { + return err + } + reblogOfStatus = s + } + + // serialize the status (or, at least, convert it to a form that's ready to be serialized) + apiModelStatus, err := t.tc.StatusToMasto(gtsStatus, relevantAccounts.StatusAuthor, t.account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, reblogOfStatus) + if err != nil { + return err + } + + // shove it in prepared posts as a prepared posts entry + preparedPostsEntry := &preparedPostsEntry{ + statusID: statusID, + prepared: apiModelStatus, + } + + return t.preparedPosts.insertPrepared(preparedPostsEntry) +} + +func (t *timeline) OldestPreparedPostID() (string, error) { + var id string + if t.preparedPosts == nil || t.preparedPosts.data == nil { + // return an empty string if prepared posts hasn't been initialized yet + return id, nil + } + + e := t.preparedPosts.data.Back() + if e == nil { + // return an empty string if there's no back entry (ie., the index list hasn't been initialized yet) + return id, nil + } + + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return id, errors.New("OldestPreparedPostID: could not parse e as a preparedPostsEntry") + } + return entry.statusID, nil +} diff --git a/internal/timeline/preparedposts.go b/internal/timeline/preparedposts.go @@ -0,0 +1,60 @@ +package timeline + +import ( + "container/list" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +) + +type preparedPosts struct { + data *list.List +} + +type preparedPostsEntry struct { + statusID string + prepared *apimodel.Status +} + +func (p *preparedPosts) insertPrepared(i *preparedPostsEntry) error { + if p.data == nil { + p.data = &list.List{} + } + + // if we have no entries yet, this is both the newest and oldest entry, so just put it in the front + if p.data.Len() == 0 { + p.data.PushFront(i) + return nil + } + + var insertMark *list.Element + // We need to iterate through the index to make sure we put this post in the appropriate place according to when it was created. + // We also need to make sure we're not inserting a duplicate post -- this can happen sometimes and it's not nice UX (*shudder*). + for e := p.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return errors.New("index: could not parse e as a preparedPostsEntry") + } + + // if the post to index is newer than e, insert it before e in the list + if insertMark == nil { + if i.statusID > entry.statusID { + insertMark = e + } + } + + // make sure we don't insert a duplicate + if entry.statusID == i.statusID { + return nil + } + } + + if insertMark != nil { + p.data.InsertBefore(i, insertMark) + return nil + } + + // if we reach this point it's the oldest post we've seen so put it at the back + p.data.PushBack(i) + return nil +} diff --git a/internal/timeline/remove.go b/internal/timeline/remove.go @@ -0,0 +1,50 @@ +package timeline + +import ( + "container/list" + "errors" +) + +func (t *timeline) Remove(statusID string) (int, error) { + t.Lock() + defer t.Unlock() + var removed int + + // remove entr(ies) from the post index + removeIndexes := []*list.Element{} + if t.postIndex != nil && t.postIndex.data != nil { + for e := t.postIndex.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*postIndexEntry) + if !ok { + return removed, errors.New("Remove: could not parse e as a postIndexEntry") + } + if entry.statusID == statusID { + removeIndexes = append(removeIndexes, e) + } + } + } + for _, e := range removeIndexes { + t.postIndex.data.Remove(e) + removed = removed + 1 + } + + // remove entr(ies) from prepared posts + removePrepared := []*list.Element{} + if t.preparedPosts != nil && t.preparedPosts.data != nil { + for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return removed, errors.New("Remove: could not parse e as a preparedPostsEntry") + } + if entry.statusID == statusID { + removePrepared = append(removePrepared, e) + } + } + } + for _, e := range removePrepared { + t.preparedPosts.data.Remove(e) + removed = removed + 1 + } + + return removed, nil +} diff --git a/internal/timeline/timeline.go b/internal/timeline/timeline.go @@ -0,0 +1,139 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package timeline + +import ( + "sync" + "time" + + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Timeline represents a timeline for one account, and contains indexed and prepared posts. +type Timeline interface { + /* + RETRIEVAL FUNCTIONS + */ + + Get(amount int, maxID string, sinceID string, minID string) ([]*apimodel.Status, error) + // GetXFromTop returns x amount of posts from the top of the timeline, from newest to oldest. + GetXFromTop(amount int) ([]*apimodel.Status, error) + // GetXBehindID returns x amount of posts from the given id onwards, from newest to oldest. + // This will NOT include the status with the given ID. + // + // This corresponds to an api call to /timelines/home?max_id=WHATEVER + GetXBehindID(amount int, fromID string) ([]*apimodel.Status, error) + // GetXBeforeID returns x amount of posts up to the given id, from newest to oldest. + // This will NOT include the status with the given ID. + // + // This corresponds to an api call to /timelines/home?since_id=WHATEVER + GetXBeforeID(amount int, sinceID string, startFromTop bool) ([]*apimodel.Status, error) + // GetXBetweenID returns x amount of posts from the given maxID, up to the given id, from newest to oldest. + // This will NOT include the status with the given IDs. + // + // This corresponds to an api call to /timelines/home?since_id=WHATEVER&max_id=WHATEVER_ELSE + GetXBetweenID(amount int, maxID string, sinceID string) ([]*apimodel.Status, error) + + /* + INDEXING FUNCTIONS + */ + + // IndexOne puts a status into the timeline at the appropriate place according to its 'createdAt' property. + IndexOne(statusCreatedAt time.Time, statusID string) error + + // OldestIndexedPostID returns the id of the rearmost (ie., the oldest) indexed post, or an error if something goes wrong. + // If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this. + OldestIndexedPostID() (string, error) + + /* + PREPARATION FUNCTIONS + */ + + // PrepareXFromTop instructs the timeline to prepare x amount of posts from the top of the timeline. + PrepareFromTop(amount int) error + // PrepareBehind instructs the timeline to prepare the next amount of entries for serialization, from position onwards. + // If include is true, then the given status ID will also be prepared, otherwise only entries behind it will be prepared. + PrepareBehind(statusID string, amount int) error + // IndexOne puts a status into the timeline at the appropriate place according to its 'createdAt' property, + // and then immediately prepares it. + IndexAndPrepareOne(statusCreatedAt time.Time, statusID string) error + // OldestPreparedPostID returns the id of the rearmost (ie., the oldest) prepared post, or an error if something goes wrong. + // If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this. + OldestPreparedPostID() (string, error) + + /* + INFO FUNCTIONS + */ + + // ActualPostIndexLength returns the actual length of the post index at this point in time. + PostIndexLength() int + + /* + UTILITY FUNCTIONS + */ + + // Reset instructs the timeline to reset to its base state -- cache only the minimum amount of posts. + Reset() error + // Remove removes a status from both the index and prepared posts. + // + // If a status has multiple entries in a timeline, they will all be removed. + // + // The returned int indicates the amount of entries that were removed. + Remove(statusID string) (int, error) +} + +// timeline fulfils the Timeline interface +type timeline struct { + postIndex *postIndex + preparedPosts *preparedPosts + accountID string + account *gtsmodel.Account + db db.DB + tc typeutils.TypeConverter + log *logrus.Logger + sync.Mutex +} + +// NewTimeline returns a new Timeline for the given account ID +func NewTimeline(accountID string, db db.DB, typeConverter typeutils.TypeConverter, log *logrus.Logger) Timeline { + return &timeline{ + postIndex: &postIndex{}, + preparedPosts: &preparedPosts{}, + accountID: accountID, + db: db, + tc: typeConverter, + log: log, + } +} + +func (t *timeline) Reset() error { + return nil +} + +func (t *timeline) PostIndexLength() int { + if t.postIndex == nil || t.postIndex.data == nil { + return 0 + } + + return t.postIndex.data.Len() +} diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go @@ -117,10 +117,13 @@ func (c *converter) ASRepresentationToAccount(accountable Accountable, update bo // url property url, err := extractURL(accountable) - if err != nil { - return nil, fmt.Errorf("could not extract url for person with id %s: %s", uri.String(), err) + if err == nil { + // take the URL if we can find it + acct.URL = url.String() + } else { + // otherwise just take the account URI as the URL + acct.URL = uri.String() } - acct.URL = url.String() // InboxURI if accountable.GetActivityStreamsInbox() != nil && accountable.GetActivityStreamsInbox().GetIRI() != nil { @@ -222,7 +225,7 @@ func (c *converter) ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, e status.APStatusOwnerURI = attributedTo.String() statusOwner := &gtsmodel.Account{} - if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: attributedTo.String()}}, statusOwner); err != nil { + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: attributedTo.String(), CaseInsensitive: true}}, statusOwner); err != nil { return nil, fmt.Errorf("couldn't get status owner from db: %s", err) } status.AccountID = statusOwner.ID diff --git a/internal/typeutils/internal.go b/internal/typeutils/internal.go @@ -4,8 +4,8 @@ import ( "fmt" "time" - "github.com/google/uuid" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -25,7 +25,10 @@ func (c *converter) FollowRequestToFollow(f *gtsmodel.FollowRequest) *gtsmodel.F func (c *converter) StatusToBoost(s *gtsmodel.Status, boostingAccount *gtsmodel.Account) (*gtsmodel.Status, error) { // the wrapper won't use the same ID as the boosted status so we generate some new UUIDs uris := util.GenerateURIsForAccount(boostingAccount.Username, c.config.Protocol, c.config.Host) - boostWrapperStatusID := uuid.NewString() + boostWrapperStatusID, err := id.NewULID() + if err != nil { + return nil, err + } boostWrapperStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, boostWrapperStatusID) boostWrapperStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, boostWrapperStatusID) @@ -56,7 +59,7 @@ func (c *converter) StatusToBoost(s *gtsmodel.Status, boostingAccount *gtsmodel. Emojis: []string{}, // the below fields will be taken from the target status - Content: util.HTMLFormat(s.Content), + Content: s.Content, ContentWarning: s.ContentWarning, ActivityStreamsType: s.ActivityStreamsType, Sensitive: s.Sensitive, diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go @@ -64,7 +64,7 @@ func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*model.Account func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, error) { // count followers followers := []gtsmodel.Follow{} - if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil { + if err := c.db.GetFollowersByAccountID(a.ID, &followers, false); err != nil { if _, ok := err.(db.ErrNoEntries); !ok { return nil, fmt.Errorf("error getting followers: %s", err) } diff --git a/internal/typeutils/wrap.go b/internal/typeutils/wrap.go @@ -6,8 +6,8 @@ import ( "github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams/vocab" - "github.com/google/uuid" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -25,7 +25,13 @@ func (c *converter) WrapPersonInUpdate(person vocab.ActivityStreamsPerson, origi update.SetActivityStreamsActor(actorProp) // set the ID - idString := util.GenerateURIForUpdate(originAccount.Username, c.config.Protocol, c.config.Host, uuid.NewString()) + + newID, err := id.NewRandomULID() + if err != nil { + return nil, err + } + + idString := util.GenerateURIForUpdate(originAccount.Username, c.config.Protocol, c.config.Host, newID) idURI, err := url.Parse(idString) if err != nil { return nil, fmt.Errorf("WrapPersonInUpdate: error parsing url %s: %s", idString, err) diff --git a/internal/util/regexes.go b/internal/util/regexes.go @@ -41,11 +41,11 @@ var ( mentionNameRegex = regexp.MustCompile(mentionNameRegexString) // mention regex can be played around with here: https://regex101.com/r/qwM9D3/1 - mentionFinderRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` + mentionFinderRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?:[^a-zA-Z0-9]|\W|$)?` mentionFinderRegex = regexp.MustCompile(mentionFinderRegexString) // hashtag regex can be played with here: https://regex101.com/r/Vhy8pg/1 - hashtagFinderRegexString = fmt.Sprintf(`(?: |^|\W)?#([a-zA-Z0-9]{1,%d})(?:\b|\r)`, maximumHashtagLength) + hashtagFinderRegexString = fmt.Sprintf(`(?:\b)?#(\w{1,%d})(?:\b)`, maximumHashtagLength) hashtagFinderRegex = regexp.MustCompile(hashtagFinderRegexString) // emoji shortcode regex can be played with here: https://regex101.com/r/zMDRaG/1 @@ -85,21 +85,25 @@ var ( // followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/following followingPathRegex = regexp.MustCompile(followingPathRegexString) - // see https://ihateregex.io/expr/uuid/ - uuidRegexString = `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}` + followPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, FollowPath, ulidRegexString) + // followPathRegex parses a path that validates and captures the username part and the ulid part + // from eg /users/example_username/follow/01F7XT5JZW1WMVSW1KADS8PVDH + followPathRegex = regexp.MustCompile(followPathRegexString) + + ulidRegexString = `[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}` likedPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, LikedPath) // likedPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked likedPathRegex = regexp.MustCompile(likedPathRegexString) - likePathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, LikedPath, uuidRegexString) - // likePathRegex parses a path that validates and captures the username part and the uuid part - // from eg /users/example_username/liked/123e4567-e89b-12d3-a456-426655440000. + likePathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, LikedPath, ulidRegexString) + // likePathRegex parses a path that validates and captures the username part and the ulid part + // from eg /users/example_username/like/01F7XT5JZW1WMVSW1KADS8PVDH likePathRegex = regexp.MustCompile(likePathRegexString) - statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, uuidRegexString) - // statusesPathRegex parses a path that validates and captures the username part and the uuid part - // from eg /users/example_username/statuses/123e4567-e89b-12d3-a456-426655440000. + statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, ulidRegexString) + // statusesPathRegex parses a path that validates and captures the username part and the ulid part + // from eg /users/example_username/statuses/01F7XT5JZW1WMVSW1KADS8PVDH // The regex can be played with here: https://regex101.com/r/G9zuxQ/1 statusesPathRegex = regexp.MustCompile(statusesPathRegexString) ) diff --git a/internal/util/statustools.go b/internal/util/statustools.go @@ -35,7 +35,7 @@ func DeriveMentionsFromStatus(status string) []string { for _, m := range mentionFinderRegex.FindAllStringSubmatch(status, -1) { mentionedAccounts = append(mentionedAccounts, m[1]) } - return lower(unique(mentionedAccounts)) + return unique(mentionedAccounts) } // DeriveHashtagsFromStatus takes a plaintext (ie., not html-formatted) status, @@ -47,7 +47,7 @@ func DeriveHashtagsFromStatus(status string) []string { for _, m := range hashtagFinderRegex.FindAllStringSubmatch(status, -1) { tags = append(tags, m[1]) } - return lower(unique(tags)) + return unique(tags) } // DeriveEmojisFromStatus takes a plaintext (ie., not html-formatted) status, @@ -59,7 +59,7 @@ func DeriveEmojisFromStatus(status string) []string { for _, m := range emojiFinderRegex.FindAllStringSubmatch(status, -1) { emojis = append(emojis, m[1]) } - return lower(unique(emojis)) + return unique(emojis) } // ExtractMentionParts extracts the username test_user and the domain example.org @@ -94,24 +94,3 @@ func unique(s []string) []string { } return list } - -// lower lowercases all strings in a given string slice -func lower(s []string) []string { - new := []string{} - for _, i := range s { - new = append(new, strings.ToLower(i)) - } - return new -} - -// HTMLFormat takes a plaintext formatted status string, and converts it into -// a nice HTML-formatted string. -// -// This includes: -// - Replacing line-breaks with <p> -// - Replacing URLs with hrefs. -// - Replacing mentions with links to that account's URL as stored in the database. -func HTMLFormat(status string) string { - // TODO: write proper HTML formatting logic for a status - return status -} diff --git a/internal/util/uri.go b/internal/util/uri.go @@ -21,7 +21,6 @@ package util import ( "fmt" "net/url" - "strings" ) const ( @@ -108,19 +107,19 @@ type UserURIs struct { } // GenerateURIForFollow returns the AP URI for a new follow -- something like: -// https://example.org/users/whatever_user/follow/41c7f33f-1060-48d9-84df-38dcb13cf0d8 +// https://example.org/users/whatever_user/follow/01F7XTH1QGBAPMGF49WJZ91XGC func GenerateURIForFollow(username string, protocol string, host string, thisFollowID string) string { return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, FollowPath, thisFollowID) } // GenerateURIForLike returns the AP URI for a new like/fave -- something like: -// https://example.org/users/whatever_user/liked/41c7f33f-1060-48d9-84df-38dcb13cf0d8 +// https://example.org/users/whatever_user/liked/01F7XTH1QGBAPMGF49WJZ91XGC func GenerateURIForLike(username string, protocol string, host string, thisFavedID string) string { return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, LikedPath, thisFavedID) } // GenerateURIForUpdate returns the AP URI for a new update activity -- something like: -// https://example.org/users/whatever_user#updates/41c7f33f-1060-48d9-84df-38dcb13cf0d8 +// https://example.org/users/whatever_user#updates/01F7XTH1QGBAPMGF49WJZ91XGC func GenerateURIForUpdate(username string, protocol string, host string, thisUpdateID string) string { return fmt.Sprintf("%s://%s/%s/%s#%s/%s", protocol, host, UsersPath, username, UpdatePath, thisUpdateID) } @@ -162,58 +161,63 @@ func GenerateURIsForAccount(username string, protocol string, host string) *User // IsUserPath returns true if the given URL path corresponds to eg /users/example_username func IsUserPath(id *url.URL) bool { - return userPathRegex.MatchString(strings.ToLower(id.Path)) + return userPathRegex.MatchString(id.Path) } // IsInboxPath returns true if the given URL path corresponds to eg /users/example_username/inbox func IsInboxPath(id *url.URL) bool { - return inboxPathRegex.MatchString(strings.ToLower(id.Path)) + return inboxPathRegex.MatchString(id.Path) } // IsOutboxPath returns true if the given URL path corresponds to eg /users/example_username/outbox func IsOutboxPath(id *url.URL) bool { - return outboxPathRegex.MatchString(strings.ToLower(id.Path)) + return outboxPathRegex.MatchString(id.Path) } // IsInstanceActorPath returns true if the given URL path corresponds to eg /actors/example_username func IsInstanceActorPath(id *url.URL) bool { - return actorPathRegex.MatchString(strings.ToLower(id.Path)) + return actorPathRegex.MatchString(id.Path) } // IsFollowersPath returns true if the given URL path corresponds to eg /users/example_username/followers func IsFollowersPath(id *url.URL) bool { - return followersPathRegex.MatchString(strings.ToLower(id.Path)) + return followersPathRegex.MatchString(id.Path) } // IsFollowingPath returns true if the given URL path corresponds to eg /users/example_username/following func IsFollowingPath(id *url.URL) bool { - return followingPathRegex.MatchString(strings.ToLower(id.Path)) + return followingPathRegex.MatchString(id.Path) +} + +// IsFollowPath returns true if the given URL path corresponds to eg /users/example_username/follow/SOME_ULID_OF_A_FOLLOW +func IsFollowPath(id *url.URL) bool { + return followPathRegex.MatchString(id.Path) } // IsLikedPath returns true if the given URL path corresponds to eg /users/example_username/liked func IsLikedPath(id *url.URL) bool { - return likedPathRegex.MatchString(strings.ToLower(id.Path)) + return likedPathRegex.MatchString(id.Path) } -// IsLikePath returns true if the given URL path corresponds to eg /users/example_username/liked/SOME_UUID_OF_A_STATUS +// IsLikePath returns true if the given URL path corresponds to eg /users/example_username/liked/SOME_ULID_OF_A_STATUS func IsLikePath(id *url.URL) bool { - return likePathRegex.MatchString(strings.ToLower(id.Path)) + return likePathRegex.MatchString(id.Path) } -// IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS +// IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_ULID_OF_A_STATUS func IsStatusesPath(id *url.URL) bool { - return statusesPathRegex.MatchString(strings.ToLower(id.Path)) + return statusesPathRegex.MatchString(id.Path) } -// ParseStatusesPath returns the username and uuid from a path such as /users/example_username/statuses/SOME_UUID_OF_A_STATUS -func ParseStatusesPath(id *url.URL) (username string, uuid string, err error) { +// ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS +func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { matches := statusesPathRegex.FindStringSubmatch(id.Path) if len(matches) != 3 { err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches)) return } username = matches[1] - uuid = matches[2] + ulid = matches[2] return } @@ -272,14 +276,14 @@ func ParseFollowingPath(id *url.URL) (username string, err error) { return } -// ParseLikedPath returns the username and uuid from a path such as /users/example_username/liked/SOME_UUID_OF_A_STATUS -func ParseLikedPath(id *url.URL) (username string, uuid string, err error) { +// ParseLikedPath returns the username and ulid from a path such as /users/example_username/liked/SOME_ULID_OF_A_STATUS +func ParseLikedPath(id *url.URL) (username string, ulid string, err error) { matches := likePathRegex.FindStringSubmatch(id.Path) if len(matches) != 3 { err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches)) return } username = matches[1] - uuid = matches[2] + ulid = matches[2] return } diff --git a/testrig/processor.go b/testrig/processor.go @@ -27,5 +27,5 @@ import ( // NewTestProcessor returns a Processor suitable for testing purposes func NewTestProcessor(db db.DB, storage blob.Storage, federator federation.Federator) processing.Processor { - return processing.NewProcessor(NewTestConfig(), NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, db, NewTestLog()) + return processing.NewProcessor(NewTestConfig(), NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, NewTestTimelineManager(db), db, NewTestLog()) } diff --git a/testrig/timelinemanager.go b/testrig/timelinemanager.go @@ -0,0 +1,11 @@ +package testrig + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/timeline" +) + +// NewTestTimelineManager retuts a new timeline.Manager, suitable for testing, using the given db. +func NewTestTimelineManager(db db.DB) timeline.Manager { + return timeline.NewManager(db, NewTestTypeConverter(db), NewTestConfig(), NewTestLog()) +}