diff options
author | 2021-04-01 20:46:45 +0200 | |
---|---|---|
committer | 2021-04-01 20:46:45 +0200 | |
commit | 71a49e2b43218d34f97b2276c43bdeb2df4a53d2 (patch) | |
tree | 201c370b16cc5446740660f81f342e8171e9903f /internal/db | |
parent | Oauth/token (#7) (diff) | |
download | gotosocial-71a49e2b43218d34f97b2276c43bdeb2df4a53d2.tar.xz |
Api/v1/accounts (#8)
* start work on accounts module
* plodding away on the accounts endpoint
* groundwork for other account routes
* add password validator
* validation utils
* require account approval flags
* comments
* comments
* go fmt
* comments
* add distributor stub
* rename api to federator
* tidy a bit
* validate new account requests
* rename r router
* comments
* add domain blocks
* add some more shortcuts
* add some more shortcuts
* check email + username availability
* email block checking for signups
* chunking away at it
* tick off a few more things
* some fiddling with tests
* add mock package
* relocate repo
* move mocks around
* set app id on new signups
* initialize oauth server properly
* rename oauth server
* proper mocking tests
* go fmt ./...
* add required fields
* change name of func
* move validation to account.go
* more tests!
* add some file utility tools
* add mediaconfig
* new shortcut
* add some more fields
* add followrequest model
* add notify
* update mastotypes
* mock out storage interface
* start building media interface
* start on update credentials
* mess about with media a bit more
* test image manipulation
* media more or less working
* account update nearly working
* rearranging my package ;) ;) ;)
* phew big stuff!!!!
* fix type checking
* *fiddles*
* Add CreateTables func
* account registration flow working
* tidy
* script to step through auth flow
* add a lil helper for generating user uris
* fiddling with federation a bit
* update progress
* Tidying and linting
Diffstat (limited to 'internal/db')
-rw-r--r-- | internal/db/actions.go | 4 | ||||
-rw-r--r-- | internal/db/db.go | 138 | ||||
-rw-r--r-- | internal/db/federating_db.go | 159 | ||||
-rw-r--r-- | internal/db/federating_db_test.go | 21 | ||||
-rw-r--r-- | internal/db/mock_DB.go | 363 | ||||
-rw-r--r-- | internal/db/model/README.md | 5 | ||||
-rw-r--r-- | internal/db/model/account.go | 164 | ||||
-rw-r--r-- | internal/db/model/application.go | 55 | ||||
-rw-r--r-- | internal/db/model/domainblock.go | 47 | ||||
-rw-r--r-- | internal/db/model/emaildomainblock.go | 35 | ||||
-rw-r--r-- | internal/db/model/follow.go | 41 | ||||
-rw-r--r-- | internal/db/model/followrequest.go | 41 | ||||
-rw-r--r-- | internal/db/model/mediaattachment.go | 136 | ||||
-rw-r--r-- | internal/db/model/status.go | 63 | ||||
-rw-r--r-- | internal/db/model/user.go | 120 | ||||
-rw-r--r-- | internal/db/pg-fed.go | 137 | ||||
-rw-r--r-- | internal/db/pg.go | 495 | ||||
-rw-r--r-- | internal/db/pg_test.go | 21 |
18 files changed, 1852 insertions, 193 deletions
diff --git a/internal/db/actions.go b/internal/db/actions.go index 01fb44b5d..4288f5fdb 100644 --- a/internal/db/actions.go +++ b/internal/db/actions.go @@ -21,9 +21,9 @@ package db import ( "context" - "github.com/gotosocial/gotosocial/internal/action" - "github.com/gotosocial/gotosocial/internal/config" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/action" + "github.com/superseriousbusiness/gotosocial/internal/config" ) // Initialize will initialize the database given in the config for use with GoToSocial diff --git a/internal/db/db.go b/internal/db/db.go index 9952e5e97..4921270e7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -21,53 +21,167 @@ package db import ( "context" "fmt" + "net" "strings" "github.com/go-fed/activity/pub" - "github.com/gotosocial/gotosocial/internal/config" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" ) const dbTypePostgres string = "POSTGRES" +// ErrNoEntries is to be returned from the DB interface when no entries are found for a given query. +type ErrNoEntries struct{} + +func (e ErrNoEntries) Error() string { + return "no entries" +} + // DB provides methods for interacting with an underlying database or other storage mechanism (for now, just postgres). +// Note that in all of the functions below, the passed interface should be a pointer or a slice, which will then be populated +// by whatever is returned from the database. type DB interface { // Federation returns an interface that's compatible with go-fed, for performing federation storage/retrieval functions. // See: https://pkg.go.dev/github.com/go-fed/activity@v1.0.0/pub?utm_source=gopls#Database Federation() pub.Database - // CreateTable creates a table for the given interface + /* + BASIC DB FUNCTIONALITY + */ + + // CreateTable creates a table for the given interface. + // For implementations that don't use tables, this can just return nil. CreateTable(i interface{}) error - // DropTable drops the table for the given interface + // DropTable drops the table for the given interface. + // For implementations that don't use tables, this can just return nil. DropTable(i interface{}) error - // Stop should stop and close the database connection cleanly, returning an error if this is not possible + // Stop should stop and close the database connection cleanly, returning an error if this is not possible. + // If the database implementation doesn't need to be stopped, this can just return nil. Stop(ctx context.Context) error - // IsHealthy should return nil if the database connection is healthy, or an error if not + // IsHealthy should return nil if the database connection is healthy, or an error if not. IsHealthy(ctx context.Context) error - // GetByID gets one entry by its id. + // GetByID gets one entry by its id. In a database like postgres, this might be the 'id' field of the entry, + // for other implementations (for example, in-memory) it might just be the key of a map. + // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. + // In case of no entries, a 'no entries' error will be returned GetByID(id string, i interface{}) error - // GetWhere gets one entry where key = value + // GetWhere gets one entry where key = value. This is similar to GetByID but allows the caller to specify the + // name of the key to select from. + // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. + // In case of no entries, a 'no entries' error will be returned GetWhere(key string, value interface{}, i interface{}) error - // GetAll gets all entries of interface type i + // GetAll will try to get all entries of type i. + // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. + // In case of no entries, a 'no entries' error will be returned GetAll(i interface{}) error - // Put stores i + // Put simply stores i. It is up to the implementation to figure out how to store it, and using what key. + // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. Put(i interface{}) error - // Update by id updates i with id id + // UpdateByID updates i with id id. + // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. UpdateByID(id string, i interface{}) error - // Delete by id removes i with id id + // UpdateOneByID updates interface i with database the given database id. It will update one field of key key and value value. + UpdateOneByID(id string, key string, value interface{}, i interface{}) error + + // DeleteByID removes i with id id. + // If i didn't exist anyway, then no error should be returned. DeleteByID(id string, i interface{}) error - // Delete where deletes i where key = value + // DeleteWhere deletes i where key = value + // If i didn't exist anyway, then no error should be returned. DeleteWhere(key string, value interface{}, i interface{}) error + + /* + HANDY SHORTCUTS + */ + + // GetAccountByUserID is a shortcut for the common action of fetching an account corresponding to a user ID. + // The given account pointer will be set to the result of the query, whatever it is. + // In case of no entries, a 'no entries' error will be returned + GetAccountByUserID(userID string, account *model.Account) error + + // GetFollowRequestsForAccountID is a shortcut for the common action of fetching a list of follow requests targeting the given account ID. + // The given slice 'followRequests' will be set to the result of the query, whatever it is. + // In case of no entries, a 'no entries' error will be returned + GetFollowRequestsForAccountID(accountID string, followRequests *[]model.FollowRequest) error + + // GetFollowingByAccountID is a shortcut for the common action of fetching a list of accounts that accountID is following. + // The given slice 'following' will be set to the result of the query, whatever it is. + // In case of no entries, a 'no entries' error will be returned + GetFollowingByAccountID(accountID string, following *[]model.Follow) error + + // 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 *[]model.Follow) error + + // GetStatusesByAccountID is a shortcut for the common action of fetching a list of statuses produced by accountID. + // The given slice 'statuses' will be set to the result of the query, whatever it is. + // In case of no entries, a 'no entries' error will be returned + GetStatusesByAccountID(accountID string, statuses *[]model.Status) error + + // GetStatusesByTimeDescending is a shortcut for getting the most recent statuses. accountID is optional, if not provided + // then all statuses will be returned. If limit is set to 0, the size of the returned slice will not be limited. This can + // be very memory intensive so you probably shouldn't do this! + // In case of no entries, a 'no entries' error will be returned + GetStatusesByTimeDescending(accountID string, statuses *[]model.Status, limit int) error + + // GetLastStatusForAccountID simply gets the most recent status by the given account. + // The given slice 'status' pointer will be set to the result of the query, whatever it is. + // In case of no entries, a 'no entries' error will be returned + GetLastStatusForAccountID(accountID string, status *model.Status) error + + // IsUsernameAvailable checks whether a given username is available on our domain. + // Returns an error if the username is already taken, or something went wrong in the db. + IsUsernameAvailable(username string) error + + // IsEmailAvailable checks whether a given email address for a new account is available to be used on our domain. + // Return an error if: + // A) the email is already associated with an account + // B) we block signups from this email domain + // C) something went wrong in the db + IsEmailAvailable(email string) error + + // NewSignup creates a new user in the database with the given parameters, with an *unconfirmed* email address. + // By the time this function is called, it should be assumed that all the parameters have passed validation! + NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*model.User, error) + + // SetHeaderOrAvatarForAccountID sets the header or avatar for the given accountID to the given media attachment. + SetHeaderOrAvatarForAccountID(mediaAttachment *model.MediaAttachment, accountID string) error + + // GetHeaderAvatarForAccountID gets the current avatar for the given account ID. + // The passed mediaAttachment pointer will be populated with the value of the avatar, if it exists. + GetAvatarForAccountID(avatar *model.MediaAttachment, accountID string) error + + // GetHeaderForAccountID gets the current header for the given account ID. + // The passed mediaAttachment pointer will be populated with the value of the header, if it exists. + GetHeaderForAccountID(header *model.MediaAttachment, accountID string) error + + /* + USEFUL CONVERSION FUNCTIONS + */ + + // AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, + // so serve it only to an authorized user who should have permission to see it. + AccountToMastoSensitive(account *model.Account) (*mastotypes.Account, error) + + // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. + // In other words, this is the public record that the server has of an account. + AccountToMastoPublic(account *model.Account) (*mastotypes.Account, error) } // New returns a new database service that satisfies the DB interface and, by extension, diff --git a/internal/db/federating_db.go b/internal/db/federating_db.go new file mode 100644 index 000000000..5b05967ea --- /dev/null +++ b/internal/db/federating_db.go @@ -0,0 +1,159 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package db + +import ( + "context" + "errors" + "net/url" + "sync" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/config" +) + +// FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface. +// It doesn't care what the underlying implementation of the DB interface is, as long as it works. +type federatingDB struct { + locks *sync.Map + db DB + config *config.Config +} + +func newFederatingDB(db DB, config *config.Config) pub.Database { + return &federatingDB{ + locks: new(sync.Map), + db: db, + config: config, + } +} + +/* + GO-FED DB INTERFACE-IMPLEMENTING FUNCTIONS +*/ +func (f *federatingDB) Lock(ctx context.Context, id *url.URL) error { + // Before any other Database methods are called, the relevant `id` + // entries are locked to allow for fine-grained concurrency. + + // Strategy: create a new lock, if stored, continue. Otherwise, lock the + // existing mutex. + mu := &sync.Mutex{} + mu.Lock() // Optimistically lock if we do store it. + i, loaded := f.locks.LoadOrStore(id.String(), mu) + if loaded { + mu = i.(*sync.Mutex) + mu.Lock() + } + return nil +} + +func (f *federatingDB) Unlock(ctx context.Context, id *url.URL) error { + // Once Go-Fed is done calling Database methods, the relevant `id` + // entries are unlocked. + + i, ok := f.locks.Load(id.String()) + if !ok { + return errors.New("missing an id in unlock") + } + mu := i.(*sync.Mutex) + mu.Unlock() + return nil +} + +func (f *federatingDB) InboxContains(ctx context.Context, inbox *url.URL, id *url.URL) (bool, error) { + return false, nil +} + +func (f *federatingDB) GetInbox(ctx context.Context, inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { + return nil, nil +} + +func (f *federatingDB) SetInbox(ctx context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error { + return nil +} + +func (f *federatingDB) Owns(ctx context.Context, id *url.URL) (owns bool, err error) { + return id.Host == f.config.Host, nil +} + +func (f *federatingDB) ActorForOutbox(ctx context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) { + return nil, nil +} + +func (f *federatingDB) ActorForInbox(ctx context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) { + return nil, nil +} + +func (f *federatingDB) OutboxForInbox(ctx context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) { + return nil, nil +} + +func (f *federatingDB) Exists(ctx context.Context, id *url.URL) (exists bool, err error) { + return false, nil +} + +func (f *federatingDB) Get(ctx context.Context, id *url.URL) (value vocab.Type, err error) { + return nil, nil +} + +func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { + t, err := streams.NewTypeResolver() + if err != nil { + return err + } + if err := t.Resolve(ctx, asType); err != nil { + return err + } + asType.GetTypeName() + return nil +} + +func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error { + return nil +} + +func (f *federatingDB) Delete(ctx context.Context, id *url.URL) error { + return nil +} + +func (f *federatingDB) GetOutbox(ctx context.Context, outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { + return nil, nil +} + +func (f *federatingDB) SetOutbox(ctx context.Context, outbox vocab.ActivityStreamsOrderedCollectionPage) error { + return nil +} + +func (f *federatingDB) NewID(ctx context.Context, t vocab.Type) (id *url.URL, err error) { + return nil, nil +} + +func (f *federatingDB) Followers(ctx context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { + return nil, nil +} + +func (f *federatingDB) Following(ctx context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { + return nil, nil +} + +func (f *federatingDB) Liked(ctx context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { + return nil, nil +} diff --git a/internal/db/federating_db_test.go b/internal/db/federating_db_test.go new file mode 100644 index 000000000..529d2efd0 --- /dev/null +++ b/internal/db/federating_db_test.go @@ -0,0 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package db + +// TODO: write tests for pgfed diff --git a/internal/db/mock_DB.go b/internal/db/mock_DB.go new file mode 100644 index 000000000..d4c25bb79 --- /dev/null +++ b/internal/db/mock_DB.go @@ -0,0 +1,363 @@ +// Code generated by mockery v2.7.4. DO NOT EDIT. + +package db + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + mastotypes "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" + + model "github.com/superseriousbusiness/gotosocial/internal/db/model" + + net "net" + + pub "github.com/go-fed/activity/pub" +) + +// MockDB is an autogenerated mock type for the DB type +type MockDB struct { + mock.Mock +} + +// AccountToMastoSensitive provides a mock function with given fields: account +func (_m *MockDB) AccountToMastoSensitive(account *model.Account) (*mastotypes.Account, error) { + ret := _m.Called(account) + + var r0 *mastotypes.Account + if rf, ok := ret.Get(0).(func(*model.Account) *mastotypes.Account); ok { + r0 = rf(account) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*mastotypes.Account) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Account) error); ok { + r1 = rf(account) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CreateTable provides a mock function with given fields: i +func (_m *MockDB) CreateTable(i interface{}) error { + ret := _m.Called(i) + + var r0 error + if rf, ok := ret.Get(0).(func(interface{}) error); ok { + r0 = rf(i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteByID provides a mock function with given fields: id, i +func (_m *MockDB) DeleteByID(id string, i interface{}) error { + ret := _m.Called(id, i) + + var r0 error + if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { + r0 = rf(id, i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteWhere provides a mock function with given fields: key, value, i +func (_m *MockDB) DeleteWhere(key string, value interface{}, i interface{}) error { + ret := _m.Called(key, value, i) + + var r0 error + if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok { + r0 = rf(key, value, i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DropTable provides a mock function with given fields: i +func (_m *MockDB) DropTable(i interface{}) error { + ret := _m.Called(i) + + var r0 error + if rf, ok := ret.Get(0).(func(interface{}) error); ok { + r0 = rf(i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Federation provides a mock function with given fields: +func (_m *MockDB) Federation() pub.Database { + ret := _m.Called() + + var r0 pub.Database + if rf, ok := ret.Get(0).(func() pub.Database); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(pub.Database) + } + } + + return r0 +} + +// GetAccountByUserID provides a mock function with given fields: userID, account +func (_m *MockDB) GetAccountByUserID(userID string, account *model.Account) error { + ret := _m.Called(userID, account) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *model.Account) error); ok { + r0 = rf(userID, account) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetAll provides a mock function with given fields: i +func (_m *MockDB) GetAll(i interface{}) error { + ret := _m.Called(i) + + var r0 error + if rf, ok := ret.Get(0).(func(interface{}) error); ok { + r0 = rf(i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetByID provides a mock function with given fields: id, i +func (_m *MockDB) GetByID(id string, i interface{}) error { + ret := _m.Called(id, i) + + var r0 error + if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { + r0 = rf(id, i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetFollowRequestsForAccountID provides a mock function with given fields: accountID, followRequests +func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests *[]model.FollowRequest) error { + ret := _m.Called(accountID, followRequests) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *[]model.FollowRequest) error); ok { + r0 = rf(accountID, followRequests) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetFollowersByAccountID provides a mock function with given fields: accountID, followers +func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]model.Follow) error { + ret := _m.Called(accountID, followers) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *[]model.Follow) error); ok { + r0 = rf(accountID, followers) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetFollowingByAccountID provides a mock function with given fields: accountID, following +func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]model.Follow) error { + ret := _m.Called(accountID, following) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *[]model.Follow) error); ok { + r0 = rf(accountID, following) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetLastStatusForAccountID provides a mock function with given fields: accountID, status +func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *model.Status) error { + ret := _m.Called(accountID, status) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *model.Status) error); ok { + r0 = rf(accountID, status) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetStatusesByAccountID provides a mock function with given fields: accountID, statuses +func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]model.Status) error { + ret := _m.Called(accountID, statuses) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *[]model.Status) error); ok { + r0 = rf(accountID, statuses) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetStatusesByTimeDescending provides a mock function with given fields: accountID, statuses, limit +func (_m *MockDB) GetStatusesByTimeDescending(accountID string, statuses *[]model.Status, limit int) error { + ret := _m.Called(accountID, statuses, limit) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *[]model.Status, int) error); ok { + r0 = rf(accountID, statuses, limit) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetWhere provides a mock function with given fields: key, value, i +func (_m *MockDB) GetWhere(key string, value interface{}, i interface{}) error { + ret := _m.Called(key, value, i) + + var r0 error + if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok { + r0 = rf(key, value, i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// IsEmailAvailable provides a mock function with given fields: email +func (_m *MockDB) IsEmailAvailable(email string) error { + ret := _m.Called(email) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(email) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// IsHealthy provides a mock function with given fields: ctx +func (_m *MockDB) IsHealthy(ctx context.Context) error { + ret := _m.Called(ctx) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// IsUsernameAvailable provides a mock function with given fields: username +func (_m *MockDB) IsUsernameAvailable(username string) error { + ret := _m.Called(username) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(username) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewSignup provides a mock function with given fields: username, reason, requireApproval, email, password, signUpIP, locale, appID +func (_m *MockDB) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*model.User, error) { + ret := _m.Called(username, reason, requireApproval, email, password, signUpIP, locale, appID) + + var r0 *model.User + if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *model.User); ok { + r0 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.User) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, bool, string, string, net.IP, string, string) error); ok { + r1 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Put provides a mock function with given fields: i +func (_m *MockDB) Put(i interface{}) error { + ret := _m.Called(i) + + var r0 error + if rf, ok := ret.Get(0).(func(interface{}) error); ok { + r0 = rf(i) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Stop provides a mock function with given fields: ctx +func (_m *MockDB) Stop(ctx context.Context) error { + ret := _m.Called(ctx) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateByID provides a mock function with given fields: id, i +func (_m *MockDB) UpdateByID(id string, i interface{}) error { + ret := _m.Called(id, i) + + var r0 error + if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { + r0 = rf(id, i) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/internal/db/model/README.md b/internal/db/model/README.md new file mode 100644 index 000000000..12a05ddec --- /dev/null +++ b/internal/db/model/README.md @@ -0,0 +1,5 @@ +# 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/db/model/account.go b/internal/db/model/account.go new file mode 100644 index 000000000..70ee92929 --- /dev/null +++ b/internal/db/model/account.go @@ -0,0 +1,164 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +// Package model contains types used *internally* by GoToSocial and added/removed/selected from the database. +// These types should never be serialized and/or sent out via public APIs, as they contain sensitive information. +// The annotation used on these structs is for handling them via the go-pg ORM (hence why they're in this db subdir). +// See here for more info on go-pg model annotations: https://pg.uptrace.dev/models/ +package model + +import ( + "crypto/rsa" + "net/url" + "time" +) + +// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc) +type Account struct { + /* + BASIC INFO + */ + + // id of this account in the local database; the end-user will never need to know this, it's strictly internal + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org`` + Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other + // Domain of the account, will be empty if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. + Domain string `pg:",unique:userdomain"` // username and domain should be unique *with* each other + + /* + ACCOUNT METADATA + */ + + // File name of the avatar on local storage + AvatarFileName string + // Gif? png? jpeg? + AvatarContentType string + // Size of the avatar in bytes + AvatarFileSize int + // When was the avatar last updated? + AvatarUpdatedAt time.Time `pg:"type:timestamp"` + // Where can the avatar be retrieved? + AvatarRemoteURL *url.URL `pg:"type:text"` + // File name of the header on local storage + HeaderFileName string + // Gif? png? jpeg? + HeaderContentType string + // Size of the header in bytes + HeaderFileSize int + // When was the header last updated? + HeaderUpdatedAt time.Time `pg:"type:timestamp"` + // Where can the header be retrieved? + HeaderRemoteURL *url.URL `pg:"type:text"` + // DisplayName for this account. Can be empty, then just the Username will be used for display purposes. + DisplayName string + // a key/value map of fields that this account has added to their profile + Fields []Field + // A note that this account has on their profile (ie., the account's bio/description of themselves) + Note string + // Is this a memorial account, ie., has the user passed away? + Memorial bool + // This account has moved this account id in the database + MovedToAccountID int + // When was this account created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this account last updated? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When should this account function until + SubscriptionExpiresAt time.Time `pg:"type:timestamp"` + // Does this account identify itself as a bot? + Bot bool + // What reason was given for signing up when this account was created? + Reason string + + /* + USER AND PRIVACY PREFERENCES + */ + + // Does this account need an approval for new followers? + Locked bool + // Should this account be shown in the instance's profile directory? + Discoverable bool + // Default post privacy for this account + Privacy string + // Set posts from this account to sensitive by default? + Sensitive bool + // What language does this account post in? + Language string + + /* + ACTIVITYPUB THINGS + */ + + // What is the activitypub URI for this account discovered by webfinger? + URI string `pg:",unique"` + // At which URL can we see the user account in a web browser? + URL string `pg:",unique"` + // Last time this account was located using the webfinger API. + LastWebfingeredAt time.Time `pg:"type:timestamp"` + // Address of this account's activitypub inbox, for sending activity to + InboxURL string `pg:",unique"` + // Address of this account's activitypub outbox + OutboxURL string `pg:",unique"` + // Don't support shared inbox right now so this is just a stub for a future implementation + SharedInboxURL string `pg:",unique"` + // URL for getting the followers list of this account + FollowersURL string `pg:",unique"` + // URL for getting the featured collection list of this account + FeaturedCollectionURL string `pg:",unique"` + // What type of activitypub actor is this account? + ActorType string + // This account is associated with x account id + AlsoKnownAs string + + /* + CRYPTO FIELDS + */ + + Secret string + // Privatekey for validating activitypub requests, will obviously only be defined for local accounts + PrivateKey *rsa.PrivateKey + // Publickey for encoding activitypub requests, will be defined for both local and remote accounts + PublicKey *rsa.PublicKey + + /* + ADMIN FIELDS + */ + + // When was this account set to have all its media shown as sensitive? + SensitizedAt time.Time `pg:"type:timestamp"` + // When was this account silenced (eg., statuses only visible to followers, not public)? + SilencedAt time.Time `pg:"type:timestamp"` + // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) + SuspendedAt time.Time `pg:"type:timestamp"` + // How much do we trust this account 🤔 + TrustLevel int + // Should we hide this account's collections? + HideCollections bool + // id of the user that suspended this account through an admin action + SuspensionOrigin int +} + +// Field represents a key value field on an account, for things like pronouns, website, etc. +// VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the +// username of the user. +type Field struct { + Name string + Value string + VerifiedAt time.Time `pg:"type:timestamp"` +} diff --git a/internal/db/model/application.go b/internal/db/model/application.go new file mode 100644 index 000000000..c8eea6430 --- /dev/null +++ b/internal/db/model/application.go @@ -0,0 +1,55 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +import "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" + +// Application represents an application that can perform actions on behalf of a user. +// 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"` + // 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') + Website string + // redirect uri requested by the application for oauth2 flow + RedirectURI string + // id of the associated oauth client entity in the db + ClientID string + // secret of the associated oauth client entity in the db + ClientSecret string + // scopes requested when this app was created + Scopes string + // a vapid key generated for this app when it was created + VapidKey string +} + +// ToMasto returns this application as a mastodon api type, ready for serialization +func (a *Application) ToMasto() *mastotypes.Application { + return &mastotypes.Application{ + ID: a.ID, + Name: a.Name, + Website: a.Website, + RedirectURI: a.RedirectURI, + ClientID: a.ClientID, + ClientSecret: a.ClientSecret, + VapidKey: a.VapidKey, + } +} diff --git a/internal/db/model/domainblock.go b/internal/db/model/domainblock.go new file mode 100644 index 000000000..e6e89bc20 --- /dev/null +++ b/internal/db/model/domainblock.go @@ -0,0 +1,47 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +import "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"` + // 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 + Domain string `pg:",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()"` + // Account ID of the creator of this block + CreatedByAccountID string `pg:",notnull"` + // TODO: define this + Severity int + // Reject media from this domain? + RejectMedia bool + // Reject reports from this domain? + RejectReports bool + // Private comment on this block, viewable to admins + PrivateComment string + // Public comment on this block, viewable (optionally) by everyone + PublicComment string +} diff --git a/internal/db/model/emaildomainblock.go b/internal/db/model/emaildomainblock.go new file mode 100644 index 000000000..6610a2075 --- /dev/null +++ b/internal/db/model/emaildomainblock.go @@ -0,0 +1,35 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +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"` + // Email domain to block. Eg. 'gmail.com' or 'hotmail.com' + Domain string `pg:",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()"` + // Account ID of the creator of this block + CreatedByAccountID string `pg:",notnull"` +} diff --git a/internal/db/model/follow.go b/internal/db/model/follow.go new file mode 100644 index 000000000..36e19e72e --- /dev/null +++ b/internal/db/model/follow.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +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"` + // 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"` + // Who does AccountID follow? + TargetAccountID string `pg:",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? + URI string `pg:",unique"` + // does the following account want to be notified when the followed account posts? + Notify bool +} diff --git a/internal/db/model/followrequest.go b/internal/db/model/followrequest.go new file mode 100644 index 000000000..50d8a5f03 --- /dev/null +++ b/internal/db/model/followrequest.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +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"` + // 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"` + // Who is the target of this follow request? + TargetAccountID string `pg:",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? + URI string `pg:",unique"` + // does the following account want to be notified when the followed account posts? + Notify bool +} diff --git a/internal/db/model/mediaattachment.go b/internal/db/model/mediaattachment.go new file mode 100644 index 000000000..3aff18d80 --- /dev/null +++ b/internal/db/model/mediaattachment.go @@ -0,0 +1,136 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +import ( + "time" +) + +// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is +// somewhere in storage and that can be retrieved and served by the router. +type MediaAttachment struct { + // ID of the attachment in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // ID of the status to which this is attached + StatusID string + // Where can the attachment be retrieved on a remote server + RemoteURL string + // When was the attachment created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was the attachment last updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Type of file (image/gif/audio/video) + Type FileType `pg:",notnull"` + // Metadata about the file + FileMeta FileMeta + // To which account does this attachment belong + AccountID string `pg:",notnull"` + // Description of the attachment (for screenreaders) + Description string + // To which scheduled status does this attachment belong + ScheduledStatusID string + // What is the generated blurhash of this attachment + Blurhash string + // What is the processing status of this attachment + Processing ProcessingStatus + // metadata for the whole file + File File + // small image thumbnail derived from a larger image, video, or audio file. + Thumbnail Thumbnail + // Is this attachment being used as an avatar? + Avatar bool + // Is this attachment being used as a header? + Header bool +} + +// File refers to the metadata for the whole file +type File struct { + // What is the path of the file in storage. + Path string + // What is the MIME content type of the file. + ContentType string + // What is the size of the file in bytes. + FileSize int + // When was the file last updated. + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` +} + +// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file. +type Thumbnail struct { + // What is the path of the file in storage + Path string + // What is the MIME content type of the file. + ContentType string + // What is the size of the file in bytes + FileSize int + // When was the file last updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // What is the remote URL of the thumbnail + RemoteURL string +} + +// ProcessingStatus refers to how far along in the processing stage the attachment is. +type ProcessingStatus int + +const ( + // ProcessingStatusReceived: the attachment has been received and is awaiting processing. No thumbnail available yet. + ProcessingStatusReceived ProcessingStatus = 0 + // ProcessingStatusProcessing: the attachment is currently being processed. Thumbnail is available but full media is not. + ProcessingStatusProcessing ProcessingStatus = 1 + // ProcessingStatusProcessed: the attachment has been fully processed and is ready to be served. + ProcessingStatusProcessed ProcessingStatus = 2 + // ProcessingStatusError: something went wrong processing the attachment and it won't be tried again--these can be deleted. + ProcessingStatusError ProcessingStatus = 666 +) + +// FileType refers to the file type of the media attaachment. +type FileType string + +const ( + // FileTypeImage is for jpegs and pngs + FileTypeImage FileType = "image" + // FileTypeGif is for native gifs and soundless videos that have been converted to gifs + FileTypeGif FileType = "gif" + // FileTypeAudio is for audio-only files (no video) + FileTypeAudio FileType = "audio" + // FileTypeVideo is for files with audio + visual + FileTypeVideo FileType = "video" +) + +// FileMeta describes metadata about the actual contents of the file. +type FileMeta struct { + Original Original + Small Small +} + +// Small implements SmallMeta and can be used for a thumbnail of any media type +type Small struct { + Width int + Height int + Size int + Aspect float64 +} + +// ImageOriginal implements OriginalMeta for still images +type Original struct { + Width int + Height int + Size int + Aspect float64 +} diff --git a/internal/db/model/status.go b/internal/db/model/status.go new file mode 100644 index 000000000..d15258727 --- /dev/null +++ b/internal/db/model/status.go @@ -0,0 +1,63 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +import "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"` + // uri at which this status is reachable + URI string `pg:",unique"` + // web url for viewing this status + URL string `pg:",unique"` + // the html-formatted content of this status + Content string + // when was this status created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // when was this status updated? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // is this status from a local account? + Local bool + // which account posted this status? + AccountID string + // id of the status this status is a reply to + InReplyToID string + // id of the status this status is a boost of + BoostOfID string + // cw string for this status + ContentWarning string + // visibility entry for this status + Visibility *Visibility +} + +// Visibility represents the visibility granularity of a status. It is a combination of flags. +type Visibility struct { + // Is this status viewable as a direct message? + Direct bool + // Is this status viewable to followers? + Followers bool + // Is this status viewable on the local timeline? + Local bool + // Is this status boostable but not shown on public timelines? + Unlisted bool + // Is this status shown on public and federated timelines? + Public bool +} diff --git a/internal/db/model/user.go b/internal/db/model/user.go new file mode 100644 index 000000000..61e9954d5 --- /dev/null +++ b/internal/db/model/user.go @@ -0,0 +1,120 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +import ( + "net" + "time" +) + +// User represents an actual human user of gotosocial. Note, this is a LOCAL gotosocial user, not a remote account. +// To cross reference this local user with their account (which can be local or remote), use the AccountID field. +type User struct { + /* + BASIC INFO + */ + + // 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"` + // 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"` + // 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"` + + /* + USER METADATA + */ + + // When was this user created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // From what IP was this user created? + SignUpIP net.IP + // When was this user updated (eg., password changed, email address changed)? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When did this user sign in for their current session? + CurrentSignInAt time.Time `pg:"type:timestamp"` + // What's the most recent IP of this user + CurrentSignInIP net.IP + // When did this user last sign in? + LastSignInAt time.Time `pg:"type:timestamp"` + // What's the previous IP of this user? + LastSignInIP net.IP + // 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 + // What languages does this user want to see? + ChosenLanguages []string + // What languages does this user not want to see? + FilteredLanguages []string + // In what timezone/locale is this user located? + Locale string + // Which application id created this user? See gtsmodel.Application + CreatedByApplicationID string + // When did we last contact this user + LastEmailedAt time.Time `pg:"type:timestamp"` + + /* + USER CONFIRMATION + */ + + // What confirmation token did we send this user/what are we expecting back? + ConfirmationToken string + // When did the user confirm their email address + ConfirmedAt time.Time `pg:"type:timestamp"` + // When did we send email confirmation to this user? + ConfirmationSentAt time.Time `pg:"type:timestamp"` + // Email address that hasn't yet been confirmed + UnconfirmedEmail string + + /* + ACL FLAGS + */ + + // Is this user a moderator? + Moderator bool + // Is this user an admin? + Admin bool + // Is this user disabled from posting? + Disabled bool + // Has this user been approved by a moderator? + Approved bool + + /* + USER SECURITY + */ + + // The generated token that the user can use to reset their password + ResetPasswordToken string + // When did we email the user their reset-password email? + ResetPasswordSentAt time.Time `pg:"type:timestamp"` + + EncryptedOTPSecret string + EncryptedOTPSecretIv string + EncryptedOTPSecretSalt string + OTPRequiredForLogin bool + OTPBackupCodes []string + ConsumedTimestamp int + RememberToken string + SignInToken string + SignInTokenSentAt time.Time `pg:"type:timestamp"` + WebauthnID string +} diff --git a/internal/db/pg-fed.go b/internal/db/pg-fed.go deleted file mode 100644 index ec1957abc..000000000 --- a/internal/db/pg-fed.go +++ /dev/null @@ -1,137 +0,0 @@ -package db - -import ( - "context" - "errors" - "net/url" - "sync" - - "github.com/go-fed/activity/pub" - "github.com/go-fed/activity/streams" - "github.com/go-fed/activity/streams/vocab" - "github.com/go-pg/pg/v10" -) - -type postgresFederation struct { - locks *sync.Map - conn *pg.DB -} - -func newPostgresFederation(conn *pg.DB) pub.Database { - return &postgresFederation{ - locks: new(sync.Map), - conn: conn, - } -} - -/* - GO-FED DB INTERFACE-IMPLEMENTING FUNCTIONS -*/ -func (pf *postgresFederation) Lock(ctx context.Context, id *url.URL) error { - // Before any other Database methods are called, the relevant `id` - // entries are locked to allow for fine-grained concurrency. - - // Strategy: create a new lock, if stored, continue. Otherwise, lock the - // existing mutex. - mu := &sync.Mutex{} - mu.Lock() // Optimistically lock if we do store it. - i, loaded := pf.locks.LoadOrStore(id.String(), mu) - if loaded { - mu = i.(*sync.Mutex) - mu.Lock() - } - return nil -} - -func (pf *postgresFederation) Unlock(ctx context.Context, id *url.URL) error { - // Once Go-Fed is done calling Database methods, the relevant `id` - // entries are unlocked. - - i, ok := pf.locks.Load(id.String()) - if !ok { - return errors.New("missing an id in unlock") - } - mu := i.(*sync.Mutex) - mu.Unlock() - return nil -} - -func (pf *postgresFederation) InboxContains(ctx context.Context, inbox *url.URL, id *url.URL) (bool, error) { - return false, nil -} - -func (pf *postgresFederation) GetInbox(ctx context.Context, inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { - return nil, nil -} - -func (pf *postgresFederation) SetInbox(ctx context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error { - return nil -} - -func (pf *postgresFederation) Owns(ctx context.Context, id *url.URL) (owns bool, err error) { - return false, nil -} - -func (pf *postgresFederation) ActorForOutbox(ctx context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) { - return nil, nil -} - -func (pf *postgresFederation) ActorForInbox(ctx context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) { - return nil, nil -} - -func (pf *postgresFederation) OutboxForInbox(ctx context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) { - return nil, nil -} - -func (pf *postgresFederation) Exists(ctx context.Context, id *url.URL) (exists bool, err error) { - return false, nil -} - -func (pf *postgresFederation) Get(ctx context.Context, id *url.URL) (value vocab.Type, err error) { - return nil, nil -} - -func (pf *postgresFederation) Create(ctx context.Context, asType vocab.Type) error { - t, err := streams.NewTypeResolver() - if err != nil { - return err - } - if err := t.Resolve(ctx, asType); err != nil { - return err - } - asType.GetTypeName() - return nil -} - -func (pf *postgresFederation) Update(ctx context.Context, asType vocab.Type) error { - return nil -} - -func (pf *postgresFederation) Delete(ctx context.Context, id *url.URL) error { - return nil -} - -func (pf *postgresFederation) GetOutbox(ctx context.Context, outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { - return nil, nil -} - -func (pf *postgresFederation) SetOutbox(ctx context.Context, outbox vocab.ActivityStreamsOrderedCollectionPage) error { - return nil -} - -func (pf *postgresFederation) NewID(ctx context.Context, t vocab.Type) (id *url.URL, err error) { - return nil, nil -} - -func (pf *postgresFederation) Followers(ctx context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { - return nil, nil -} - -func (pf *postgresFederation) Following(ctx context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { - return nil, nil -} - -func (pf *postgresFederation) Liked(ctx context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { - return nil, nil -} diff --git a/internal/db/pg.go b/internal/db/pg.go index 487af184f..df01132c2 100644 --- a/internal/db/pg.go +++ b/internal/db/pg.go @@ -20,8 +20,12 @@ package db import ( "context" + "crypto/rand" + "crypto/rsa" "errors" "fmt" + "net" + "net/mail" "regexp" "strings" "time" @@ -30,14 +34,17 @@ import ( "github.com/go-pg/pg/extra/pgdebug" "github.com/go-pg/pg/v10" "github.com/go-pg/pg/v10/orm" - "github.com/gotosocial/gotosocial/internal/config" - "github.com/gotosocial/gotosocial/internal/gtsmodel" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" + "golang.org/x/crypto/bcrypt" ) // postgresService satisfies the DB interface type postgresService struct { - config *config.DBConfig + config *config.Config conn *pg.DB log *logrus.Entry cancel context.CancelFunc @@ -46,7 +53,7 @@ type postgresService struct { // newPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface. // Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection. -func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry) (*postgresService, error) { +func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry) (DB, error) { opts, err := derivePGOptions(c) if err != nil { return nil, fmt.Errorf("could not create postgres service: %s", err) @@ -98,18 +105,18 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry return nil, errors.New("db connection timeout") } - // we can confidently return this useable postgres service now - return &postgresService{ - config: c.DBConfig, - conn: conn, - log: log, - cancel: cancel, - federationDB: newPostgresFederation(conn), - }, nil -} + ps := &postgresService{ + config: c, + conn: conn, + log: log, + cancel: cancel, + } -func (ps *postgresService) Federation() pub.Database { - return ps.federationDB + federatingDB := newFederatingDB(ps, c) + ps.federationDB = federatingDB + + // we can confidently return this useable postgres service now + return ps, nil } /* @@ -168,9 +175,29 @@ func derivePGOptions(c *config.Config) (*pg.Options, error) { } /* - EXTRA FUNCTIONS + FEDERATION FUNCTIONALITY */ +func (ps *postgresService) Federation() pub.Database { + return ps.federationDB +} + +/* + BASIC DB FUNCTIONALITY +*/ + +func (ps *postgresService) CreateTable(i interface{}) error { + return ps.conn.Model(i).CreateTable(&orm.CreateTableOptions{ + IfNotExists: true, + }) +} + +func (ps *postgresService) DropTable(i interface{}) error { + return ps.conn.Model(i).DropTable(&orm.DropTableOptions{ + IfExists: true, + }) +} + func (ps *postgresService) Stop(ctx context.Context) error { ps.log.Info("closing db connection") if err := ps.conn.Close(); err != nil { @@ -181,11 +208,15 @@ func (ps *postgresService) Stop(ctx context.Context) error { return nil } +func (ps *postgresService) IsHealthy(ctx context.Context) error { + return ps.conn.Ping(ctx) +} + func (ps *postgresService) CreateSchema(ctx context.Context) error { models := []interface{}{ - (*gtsmodel.Account)(nil), - (*gtsmodel.Status)(nil), - (*gtsmodel.User)(nil), + (*model.Account)(nil), + (*model.Status)(nil), + (*model.User)(nil), } ps.log.Info("creating db schema") @@ -202,32 +233,35 @@ func (ps *postgresService) CreateSchema(ctx context.Context) error { return nil } -func (ps *postgresService) IsHealthy(ctx context.Context) error { - return ps.conn.Ping(ctx) -} - -func (ps *postgresService) CreateTable(i interface{}) error { - return ps.conn.Model(i).CreateTable(&orm.CreateTableOptions{ - IfNotExists: true, - }) -} - -func (ps *postgresService) DropTable(i interface{}) error { - return ps.conn.Model(i).DropTable(&orm.DropTableOptions{ - IfExists: true, - }) -} - func (ps *postgresService) GetByID(id string, i interface{}) error { - return ps.conn.Model(i).Where("id = ?", id).Select() + if err := ps.conn.Model(i).Where("id = ?", id).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + + } + return nil } func (ps *postgresService) GetWhere(key string, value interface{}, i interface{}) error { - return ps.conn.Model(i).Where(fmt.Sprintf("%s = ?", key), value).Select() + if err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil } func (ps *postgresService) GetAll(i interface{}) error { - return ps.conn.Model(i).Select() + if err := ps.conn.Model(i).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil } func (ps *postgresService) Put(i interface{}) error { @@ -236,16 +270,393 @@ func (ps *postgresService) Put(i interface{}) error { } func (ps *postgresService) UpdateByID(id string, i interface{}) error { - _, err := ps.conn.Model(i).OnConflict("(id) DO UPDATE").Insert() + if _, err := ps.conn.Model(i).OnConflict("(id) DO UPDATE").Insert(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) UpdateOneByID(id string, key string, value interface{}, i interface{}) error { + _, err := ps.conn.Model(i).Set("? = ?", pg.Safe(key), value).Where("id = ?", id).Update() return err } func (ps *postgresService) DeleteByID(id string, i interface{}) error { - _, err := ps.conn.Model(i).Where("id = ?", id).Delete() - return err + if _, err := ps.conn.Model(i).Where("id = ?", id).Delete(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil } func (ps *postgresService) DeleteWhere(key string, value interface{}, i interface{}) error { - _, err := ps.conn.Model(i).Where(fmt.Sprintf("%s = ?", key), value).Delete() + if _, err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Delete(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +/* + HANDY SHORTCUTS +*/ + +func (ps *postgresService) GetAccountByUserID(userID string, account *model.Account) error { + user := &model.User{ + ID: userID, + } + if err := ps.conn.Model(user).Where("id = ?", userID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + if err := ps.conn.Model(account).Where("id = ?", user.AccountID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]model.FollowRequest) error { + if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowingByAccountID(accountID string, following *[]model.Follow) error { + if err := ps.conn.Model(following).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]model.Follow) error { + if err := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]model.Status) error { + if err := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]model.Status, limit int) error { + q := ps.conn.Model(statuses).Order("created_at DESC") + if limit != 0 { + q = q.Limit(limit) + } + if accountID != "" { + q = q.Where("account_id = ?", accountID) + } + if err := q.Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *model.Status) error { + if err := ps.conn.Model(status).Order("created_at DESC").Limit(1).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil + +} + +func (ps *postgresService) IsUsernameAvailable(username string) error { + // if no error we fail because it means we found something + // if error but it's not pg.ErrNoRows then we fail + // if err is pg.ErrNoRows we're good, we found nothing so continue + if err := ps.conn.Model(&model.Account{}).Where("username = ?", username).Where("domain = ?", nil).Select(); err == nil { + return fmt.Errorf("username %s already in use", username) + } else if err != pg.ErrNoRows { + return fmt.Errorf("db error: %s", err) + } + return nil +} + +func (ps *postgresService) IsEmailAvailable(email string) error { + // parse the domain from the email + m, err := mail.ParseAddress(email) + if err != nil { + return fmt.Errorf("error parsing email address %s: %s", email, err) + } + domain := strings.Split(m.Address, "@")[1] // domain will always be the second part after @ + + // check if the email domain is blocked + if err := ps.conn.Model(&model.EmailDomainBlock{}).Where("domain = ?", domain).Select(); err == nil { + // fail because we found something + return fmt.Errorf("email domain %s is blocked", domain) + } else if err != pg.ErrNoRows { + // fail because we got an unexpected error + return fmt.Errorf("db error: %s", err) + } + + // check if this email is associated with a user already + if err := ps.conn.Model(&model.User{}).Where("email = ?", email).WhereOr("unconfirmed_email = ?", email).Select(); err == nil { + // fail because we found something + return fmt.Errorf("email %s already in use", email) + } else if err != pg.ErrNoRows { + // fail because we got an unexpected error + return fmt.Errorf("db error: %s", err) + } + return nil +} + +func (ps *postgresService) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*model.User, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + ps.log.Errorf("error creating new rsa key: %s", err) + return nil, err + } + + uris := util.GenerateURIs(username, ps.config.Protocol, ps.config.Host) + + a := &model.Account{ + Username: username, + DisplayName: username, + Reason: reason, + URL: uris.UserURL, + PrivateKey: key, + PublicKey: &key.PublicKey, + ActorType: "Person", + URI: uris.UserURI, + InboxURL: uris.InboxURL, + OutboxURL: uris.OutboxURL, + FollowersURL: uris.FollowersURL, + FeaturedCollectionURL: uris.CollectionURL, + } + if _, err = ps.conn.Model(a).Insert(); err != nil { + return nil, err + } + + pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("error hashing password: %s", err) + } + u := &model.User{ + AccountID: a.ID, + EncryptedPassword: string(pw), + SignUpIP: signUpIP, + Locale: locale, + UnconfirmedEmail: email, + CreatedByApplicationID: appID, + Approved: !requireApproval, // if we don't require moderator approval, just pre-approve the user + } + if _, err = ps.conn.Model(u).Insert(); err != nil { + return nil, err + } + + return u, nil +} + +func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *model.MediaAttachment, accountID string) error { + _, err := ps.conn.Model(mediaAttachment).Insert() return err } + +func (ps *postgresService) GetHeaderForAccountID(header *model.MediaAttachment, accountID string) error { + if err := ps.conn.Model(header).Where("account_id = ?", accountID).Where("header = ?", true).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetAvatarForAccountID(avatar *model.MediaAttachment, accountID string) error { + if err := ps.conn.Model(avatar).Where("account_id = ?", accountID).Where("avatar = ?", true).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + +/* + CONVERSION FUNCTIONS +*/ + +// AccountToMastoSensitive takes an internal account model and transforms it into an account ready to be served through the API. +// The resulting account fits the specifications for the path /api/v1/accounts/verify_credentials, as described here: +// https://docs.joinmastodon.org/methods/accounts/. Note that it's *sensitive* because it's only meant to be exposed to the user +// that the account actually belongs to. +func (ps *postgresService) AccountToMastoSensitive(a *model.Account) (*mastotypes.Account, error) { + // we can build this sensitive account easily by first getting the public account.... + mastoAccount, err := ps.AccountToMastoPublic(a) + if err != nil { + return nil, err + } + + // then adding the Source object to it... + + // check pending follow requests aimed at this account + fr := []model.FollowRequest{} + if err := ps.GetFollowRequestsForAccountID(a.ID, &fr); err != nil { + if _, ok := err.(ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting follow requests: %s", err) + } + } + var frc int + if fr != nil { + frc = len(fr) + } + + mastoAccount.Source = &mastotypes.Source{ + Privacy: a.Privacy, + Sensitive: a.Sensitive, + Language: a.Language, + Note: a.Note, + Fields: mastoAccount.Fields, + FollowRequestsCount: frc, + } + + return mastoAccount, nil +} + +func (ps *postgresService) AccountToMastoPublic(a *model.Account) (*mastotypes.Account, error) { + // count followers + followers := []model.Follow{} + if err := ps.GetFollowersByAccountID(a.ID, &followers); err != nil { + if _, ok := err.(ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting followers: %s", err) + } + } + var followersCount int + if followers != nil { + followersCount = len(followers) + } + + // count following + following := []model.Follow{} + if err := ps.GetFollowingByAccountID(a.ID, &following); err != nil { + if _, ok := err.(ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting following: %s", err) + } + } + var followingCount int + if following != nil { + followingCount = len(following) + } + + // count statuses + statuses := []model.Status{} + if err := ps.GetStatusesByAccountID(a.ID, &statuses); err != nil { + if _, ok := err.(ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting last statuses: %s", err) + } + } + var statusesCount int + if statuses != nil { + statusesCount = len(statuses) + } + + // check when the last status was + lastStatus := &model.Status{} + if err := ps.GetLastStatusForAccountID(a.ID, lastStatus); err != nil { + if _, ok := err.(ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting last status: %s", err) + } + } + var lastStatusAt string + if lastStatus != nil { + lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339) + } + + // build the avatar and header URLs + avi := &model.MediaAttachment{} + if err := ps.GetAvatarForAccountID(avi, a.ID); err != nil { + if _, ok := err.(ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting avatar: %s", err) + } + } + aviURL := avi.File.Path + aviURLStatic := avi.Thumbnail.Path + + header := &model.MediaAttachment{} + if err := ps.GetHeaderForAccountID(avi, a.ID); err != nil { + if _, ok := err.(ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting header: %s", err) + } + } + headerURL := header.File.Path + headerURLStatic := header.Thumbnail.Path + + // get the fields set on this account + fields := []mastotypes.Field{} + for _, f := range a.Fields { + mField := mastotypes.Field{ + Name: f.Name, + Value: f.Value, + } + if !f.VerifiedAt.IsZero() { + mField.VerifiedAt = f.VerifiedAt.Format(time.RFC3339) + } + fields = append(fields, mField) + } + + var acct string + if a.Domain != "" { + // this is a remote user + acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) + } else { + // this is a local user + acct = a.Username + } + + return &mastotypes.Account{ + ID: a.ID, + Username: a.Username, + Acct: acct, + DisplayName: a.DisplayName, + Locked: a.Locked, + Bot: a.Bot, + CreatedAt: a.CreatedAt.Format(time.RFC3339), + Note: a.Note, + URL: a.URL, + Avatar: aviURL, + AvatarStatic: aviURLStatic, + Header: headerURL, + HeaderStatic: headerURLStatic, + FollowersCount: followersCount, + FollowingCount: followingCount, + StatusesCount: statusesCount, + LastStatusAt: lastStatusAt, + Emojis: nil, // TODO: implement this + Fields: fields, + }, nil +} diff --git a/internal/db/pg_test.go b/internal/db/pg_test.go new file mode 100644 index 000000000..f9bd21c48 --- /dev/null +++ b/internal/db/pg_test.go @@ -0,0 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package db + +// TODO: write tests for postgres |