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
This commit is contained in:
Tobi Smethurst
2021-04-01 20:46:45 +02:00
committed by GitHub
parent aa9ce272dc
commit 71a49e2b43
94 changed files with 6585 additions and 955 deletions

View File

@ -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

View File

@ -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,

View File

@ -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
}

View File

@ -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

363
internal/db/mock_DB.go Normal file
View File

@ -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
}

View File

@ -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/).

View File

@ -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"`
}

View File

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

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

120
internal/db/model/user.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

21
internal/db/pg_test.go Normal file
View File

@ -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