diff --git a/admindb/interface.go b/admindb/interface.go index f574624..f2592f0 100644 --- a/admindb/interface.go +++ b/admindb/interface.go @@ -1,5 +1,13 @@ // SPDX-License-Identifier: MIT +// Package admindb implements all the persisted database needs of the room server. +// This includes authentication, allow/deny list managment, invite and alias creation and also the notice content for the CMS. +// +// The interfaces defined here are implemented twice. Once in SQLite for production and once as mocks for testing, generated by counterfeiter (https://github.com/maxbrunsfeld/counterfeiter). +// +// See the package documentation of admindb/sqlite for how to update it. +// It's important not to use the types generated by sqlboiler (sqlite/models) in the argument and return values of the interfaces here. +// This would leak details of the internal implementation of the admindb/sqlite package and we want to have full control over how these interfaces can be used. package admindb import ( @@ -13,7 +21,7 @@ import ( type AuthFallbackService interface { auth.Auther - Create(ctx context.Context, user string, password []byte) error + Create(ctx context.Context, user string, password []byte) (int64, error) GetByID(ctx context.Context, uid int64) (*User, error) } @@ -47,6 +55,25 @@ type AllowListService interface { // AliasService manages alias handle registration and lookup type AliasService interface{} +// InviteService manages creation and consumption of invite tokens for joining the room. +type InviteService interface { + + // Create creates a new invite for a new member. It returns the token or an error. + // createdBy is user ID of the admin or moderator who created it. + // aliasSuggestion is optional (empty string is fine) but can be used to disambiguate open invites. (See https://github.com/ssb-ngi-pointer/rooms2/issues/21) + Create(ctx context.Context, createdBy int64, aliasSuggestion string) (string, error) + + // Consume checks if the passed token is still valid. If it is it adds newMember to the members of the room and invalidates the token. + // If the token isn't valid, it returns an error. + Consume(ctx context.Context, token string, newMember refs.FeedRef) (Invite, error) + + // List returns a list of all the valid invites + List(ctx context.Context) ([]Invite, error) + + // Revoke removes a active invite and invalidates it for future use. + Revoke(ctx context.Context, id int64) error +} + // PinnedNoticesService allows an admin to assign Notices to specific placeholder pages. // like updates, privacy policy, code of conduct type PinnedNoticesService interface { @@ -82,6 +109,8 @@ type NoticesService interface { //go:generate counterfeiter -o mockdb/alias.go . AliasService +//go:generate counterfeiter -o mockdb/invite.go . InviteService + //go:generate counterfeiter -o mockdb/fixed_pages.go . PinnedNoticesService //go:generate counterfeiter -o mockdb/pages.go . NoticesService diff --git a/admindb/mockdb/auth_fallback.go b/admindb/mockdb/auth_fallback.go index d8ca856..095a01d 100644 --- a/admindb/mockdb/auth_fallback.go +++ b/admindb/mockdb/auth_fallback.go @@ -23,7 +23,7 @@ type FakeAuthFallbackService struct { result1 interface{} result2 error } - CreateStub func(context.Context, string, []byte) error + CreateStub func(context.Context, string, []byte) (int64, error) createMutex sync.RWMutex createArgsForCall []struct { arg1 context.Context @@ -31,10 +31,12 @@ type FakeAuthFallbackService struct { arg3 []byte } createReturns struct { - result1 error + result1 int64 + result2 error } createReturnsOnCall map[int]struct { - result1 error + result1 int64 + result2 error } GetByIDStub func(context.Context, int64) (*admindb.User, error) getByIDMutex sync.RWMutex @@ -119,7 +121,7 @@ func (fake *FakeAuthFallbackService) CheckReturnsOnCall(i int, result1 interface }{result1, result2} } -func (fake *FakeAuthFallbackService) Create(arg1 context.Context, arg2 string, arg3 []byte) error { +func (fake *FakeAuthFallbackService) Create(arg1 context.Context, arg2 string, arg3 []byte) (int64, error) { var arg3Copy []byte if arg3 != nil { arg3Copy = make([]byte, len(arg3)) @@ -140,9 +142,9 @@ func (fake *FakeAuthFallbackService) Create(arg1 context.Context, arg2 string, a return stub(arg1, arg2, arg3) } if specificReturn { - return ret.result1 + return ret.result1, ret.result2 } - return fakeReturns.result1 + return fakeReturns.result1, fakeReturns.result2 } func (fake *FakeAuthFallbackService) CreateCallCount() int { @@ -151,7 +153,7 @@ func (fake *FakeAuthFallbackService) CreateCallCount() int { return len(fake.createArgsForCall) } -func (fake *FakeAuthFallbackService) CreateCalls(stub func(context.Context, string, []byte) error) { +func (fake *FakeAuthFallbackService) CreateCalls(stub func(context.Context, string, []byte) (int64, error)) { fake.createMutex.Lock() defer fake.createMutex.Unlock() fake.CreateStub = stub @@ -164,27 +166,30 @@ func (fake *FakeAuthFallbackService) CreateArgsForCall(i int) (context.Context, return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } -func (fake *FakeAuthFallbackService) CreateReturns(result1 error) { +func (fake *FakeAuthFallbackService) CreateReturns(result1 int64, result2 error) { fake.createMutex.Lock() defer fake.createMutex.Unlock() fake.CreateStub = nil fake.createReturns = struct { - result1 error - }{result1} + result1 int64 + result2 error + }{result1, result2} } -func (fake *FakeAuthFallbackService) CreateReturnsOnCall(i int, result1 error) { +func (fake *FakeAuthFallbackService) CreateReturnsOnCall(i int, result1 int64, result2 error) { fake.createMutex.Lock() defer fake.createMutex.Unlock() fake.CreateStub = nil if fake.createReturnsOnCall == nil { fake.createReturnsOnCall = make(map[int]struct { - result1 error + result1 int64 + result2 error }) } fake.createReturnsOnCall[i] = struct { - result1 error - }{result1} + result1 int64 + result2 error + }{result1, result2} } func (fake *FakeAuthFallbackService) GetByID(arg1 context.Context, arg2 int64) (*admindb.User, error) { diff --git a/admindb/mockdb/invite.go b/admindb/mockdb/invite.go new file mode 100644 index 0000000..8a38230 --- /dev/null +++ b/admindb/mockdb/invite.go @@ -0,0 +1,360 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package mockdb + +import ( + "context" + "sync" + + "github.com/ssb-ngi-pointer/go-ssb-room/admindb" + refs "go.mindeco.de/ssb-refs" +) + +type FakeInviteService struct { + ConsumeStub func(context.Context, string, refs.FeedRef) (admindb.Invite, error) + consumeMutex sync.RWMutex + consumeArgsForCall []struct { + arg1 context.Context + arg2 string + arg3 refs.FeedRef + } + consumeReturns struct { + result1 admindb.Invite + result2 error + } + consumeReturnsOnCall map[int]struct { + result1 admindb.Invite + result2 error + } + CreateStub func(context.Context, int64, string) (string, error) + createMutex sync.RWMutex + createArgsForCall []struct { + arg1 context.Context + arg2 int64 + arg3 string + } + createReturns struct { + result1 string + result2 error + } + createReturnsOnCall map[int]struct { + result1 string + result2 error + } + ListStub func(context.Context) ([]admindb.Invite, error) + listMutex sync.RWMutex + listArgsForCall []struct { + arg1 context.Context + } + listReturns struct { + result1 []admindb.Invite + result2 error + } + listReturnsOnCall map[int]struct { + result1 []admindb.Invite + result2 error + } + RevokeStub func(context.Context, int64) error + revokeMutex sync.RWMutex + revokeArgsForCall []struct { + arg1 context.Context + arg2 int64 + } + revokeReturns struct { + result1 error + } + revokeReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeInviteService) Consume(arg1 context.Context, arg2 string, arg3 refs.FeedRef) (admindb.Invite, error) { + fake.consumeMutex.Lock() + ret, specificReturn := fake.consumeReturnsOnCall[len(fake.consumeArgsForCall)] + fake.consumeArgsForCall = append(fake.consumeArgsForCall, struct { + arg1 context.Context + arg2 string + arg3 refs.FeedRef + }{arg1, arg2, arg3}) + stub := fake.ConsumeStub + fakeReturns := fake.consumeReturns + fake.recordInvocation("Consume", []interface{}{arg1, arg2, arg3}) + fake.consumeMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeInviteService) ConsumeCallCount() int { + fake.consumeMutex.RLock() + defer fake.consumeMutex.RUnlock() + return len(fake.consumeArgsForCall) +} + +func (fake *FakeInviteService) ConsumeCalls(stub func(context.Context, string, refs.FeedRef) (admindb.Invite, error)) { + fake.consumeMutex.Lock() + defer fake.consumeMutex.Unlock() + fake.ConsumeStub = stub +} + +func (fake *FakeInviteService) ConsumeArgsForCall(i int) (context.Context, string, refs.FeedRef) { + fake.consumeMutex.RLock() + defer fake.consumeMutex.RUnlock() + argsForCall := fake.consumeArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeInviteService) ConsumeReturns(result1 admindb.Invite, result2 error) { + fake.consumeMutex.Lock() + defer fake.consumeMutex.Unlock() + fake.ConsumeStub = nil + fake.consumeReturns = struct { + result1 admindb.Invite + result2 error + }{result1, result2} +} + +func (fake *FakeInviteService) ConsumeReturnsOnCall(i int, result1 admindb.Invite, result2 error) { + fake.consumeMutex.Lock() + defer fake.consumeMutex.Unlock() + fake.ConsumeStub = nil + if fake.consumeReturnsOnCall == nil { + fake.consumeReturnsOnCall = make(map[int]struct { + result1 admindb.Invite + result2 error + }) + } + fake.consumeReturnsOnCall[i] = struct { + result1 admindb.Invite + result2 error + }{result1, result2} +} + +func (fake *FakeInviteService) Create(arg1 context.Context, arg2 int64, arg3 string) (string, error) { + fake.createMutex.Lock() + ret, specificReturn := fake.createReturnsOnCall[len(fake.createArgsForCall)] + fake.createArgsForCall = append(fake.createArgsForCall, struct { + arg1 context.Context + arg2 int64 + arg3 string + }{arg1, arg2, arg3}) + stub := fake.CreateStub + fakeReturns := fake.createReturns + fake.recordInvocation("Create", []interface{}{arg1, arg2, arg3}) + fake.createMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeInviteService) CreateCallCount() int { + fake.createMutex.RLock() + defer fake.createMutex.RUnlock() + return len(fake.createArgsForCall) +} + +func (fake *FakeInviteService) CreateCalls(stub func(context.Context, int64, string) (string, error)) { + fake.createMutex.Lock() + defer fake.createMutex.Unlock() + fake.CreateStub = stub +} + +func (fake *FakeInviteService) CreateArgsForCall(i int) (context.Context, int64, string) { + fake.createMutex.RLock() + defer fake.createMutex.RUnlock() + argsForCall := fake.createArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeInviteService) CreateReturns(result1 string, result2 error) { + fake.createMutex.Lock() + defer fake.createMutex.Unlock() + fake.CreateStub = nil + fake.createReturns = struct { + result1 string + result2 error + }{result1, result2} +} + +func (fake *FakeInviteService) CreateReturnsOnCall(i int, result1 string, result2 error) { + fake.createMutex.Lock() + defer fake.createMutex.Unlock() + fake.CreateStub = nil + if fake.createReturnsOnCall == nil { + fake.createReturnsOnCall = make(map[int]struct { + result1 string + result2 error + }) + } + fake.createReturnsOnCall[i] = struct { + result1 string + result2 error + }{result1, result2} +} + +func (fake *FakeInviteService) List(arg1 context.Context) ([]admindb.Invite, error) { + fake.listMutex.Lock() + ret, specificReturn := fake.listReturnsOnCall[len(fake.listArgsForCall)] + fake.listArgsForCall = append(fake.listArgsForCall, struct { + arg1 context.Context + }{arg1}) + stub := fake.ListStub + fakeReturns := fake.listReturns + fake.recordInvocation("List", []interface{}{arg1}) + fake.listMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeInviteService) ListCallCount() int { + fake.listMutex.RLock() + defer fake.listMutex.RUnlock() + return len(fake.listArgsForCall) +} + +func (fake *FakeInviteService) ListCalls(stub func(context.Context) ([]admindb.Invite, error)) { + fake.listMutex.Lock() + defer fake.listMutex.Unlock() + fake.ListStub = stub +} + +func (fake *FakeInviteService) ListArgsForCall(i int) context.Context { + fake.listMutex.RLock() + defer fake.listMutex.RUnlock() + argsForCall := fake.listArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeInviteService) ListReturns(result1 []admindb.Invite, result2 error) { + fake.listMutex.Lock() + defer fake.listMutex.Unlock() + fake.ListStub = nil + fake.listReturns = struct { + result1 []admindb.Invite + result2 error + }{result1, result2} +} + +func (fake *FakeInviteService) ListReturnsOnCall(i int, result1 []admindb.Invite, result2 error) { + fake.listMutex.Lock() + defer fake.listMutex.Unlock() + fake.ListStub = nil + if fake.listReturnsOnCall == nil { + fake.listReturnsOnCall = make(map[int]struct { + result1 []admindb.Invite + result2 error + }) + } + fake.listReturnsOnCall[i] = struct { + result1 []admindb.Invite + result2 error + }{result1, result2} +} + +func (fake *FakeInviteService) Revoke(arg1 context.Context, arg2 int64) error { + fake.revokeMutex.Lock() + ret, specificReturn := fake.revokeReturnsOnCall[len(fake.revokeArgsForCall)] + fake.revokeArgsForCall = append(fake.revokeArgsForCall, struct { + arg1 context.Context + arg2 int64 + }{arg1, arg2}) + stub := fake.RevokeStub + fakeReturns := fake.revokeReturns + fake.recordInvocation("Revoke", []interface{}{arg1, arg2}) + fake.revokeMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeInviteService) RevokeCallCount() int { + fake.revokeMutex.RLock() + defer fake.revokeMutex.RUnlock() + return len(fake.revokeArgsForCall) +} + +func (fake *FakeInviteService) RevokeCalls(stub func(context.Context, int64) error) { + fake.revokeMutex.Lock() + defer fake.revokeMutex.Unlock() + fake.RevokeStub = stub +} + +func (fake *FakeInviteService) RevokeArgsForCall(i int) (context.Context, int64) { + fake.revokeMutex.RLock() + defer fake.revokeMutex.RUnlock() + argsForCall := fake.revokeArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeInviteService) RevokeReturns(result1 error) { + fake.revokeMutex.Lock() + defer fake.revokeMutex.Unlock() + fake.RevokeStub = nil + fake.revokeReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeInviteService) RevokeReturnsOnCall(i int, result1 error) { + fake.revokeMutex.Lock() + defer fake.revokeMutex.Unlock() + fake.RevokeStub = nil + if fake.revokeReturnsOnCall == nil { + fake.revokeReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.revokeReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeInviteService) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.consumeMutex.RLock() + defer fake.consumeMutex.RUnlock() + fake.createMutex.RLock() + defer fake.createMutex.RUnlock() + fake.listMutex.RLock() + defer fake.listMutex.RUnlock() + fake.revokeMutex.RLock() + defer fake.revokeMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeInviteService) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ admindb.InviteService = new(FakeInviteService) diff --git a/admindb/sqlite/aliases.go b/admindb/sqlite/aliases.go index d0582d6..2b798e9 100644 --- a/admindb/sqlite/aliases.go +++ b/admindb/sqlite/aliases.go @@ -8,7 +8,7 @@ import ( "github.com/ssb-ngi-pointer/go-ssb-room/admindb" ) -// make sure to implement interfaces correctly +// compiler assertion to ensure the struct fullfills the interface var _ admindb.AliasService = (*Aliases)(nil) type Aliases struct { diff --git a/admindb/sqlite/auth_fallback.go b/admindb/sqlite/auth_fallback.go index d2dca3a..08a2609 100644 --- a/admindb/sqlite/auth_fallback.go +++ b/admindb/sqlite/auth_fallback.go @@ -17,7 +17,7 @@ import ( "github.com/ssb-ngi-pointer/go-ssb-room/admindb" ) -// make sure to implement interfaces correctly +// compiler assertion to ensure the struct fullfills the interface var _ admindb.AuthFallbackService = (*AuthFallback)(nil) type AuthFallback struct { @@ -39,23 +39,23 @@ func (ah AuthFallback) Check(name, password string) (interface{}, error) { return found.ID, nil } -func (ah AuthFallback) Create(ctx context.Context, name string, password []byte) error { +func (ah AuthFallback) Create(ctx context.Context, name string, password []byte) (int64, error) { var u models.AuthFallback u.Name = name hashed, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost) if err != nil { - return fmt.Errorf("auth/fallback: failed to hash password for new user") + return -1, fmt.Errorf("auth/fallback: failed to hash password for new user") } u.PasswordHash = hashed err = u.Insert(ctx, ah.db, boil.Infer()) if err != nil { - return fmt.Errorf("auth/fallback: failed to insert new user: %w", err) + return -1, fmt.Errorf("auth/fallback: failed to insert new user: %w", err) } - return nil + return u.ID, nil } func (ah AuthFallback) GetByID(ctx context.Context, uid int64) (*admindb.User, error) { diff --git a/admindb/sqlite/auth_withssb.go b/admindb/sqlite/auth_withssb.go index 19b2c19..fc4232f 100644 --- a/admindb/sqlite/auth_withssb.go +++ b/admindb/sqlite/auth_withssb.go @@ -8,7 +8,7 @@ import ( "github.com/ssb-ngi-pointer/go-ssb-room/admindb" ) -// make sure to implement interfaces correctly +// compiler assertion to ensure the struct fullfills the interface var _ admindb.AuthWithSSBService = (*AuthWithSSB)(nil) type AuthWithSSB struct { diff --git a/admindb/sqlite/generate_models.sh b/admindb/sqlite/generate_models.sh index 2fc80ac..fbaea5a 100644 --- a/admindb/sqlite/generate_models.sh +++ b/admindb/sqlite/generate_models.sh @@ -2,6 +2,20 @@ set -e -go test +# ensure tools are installed +go get github.com/volatiletech/sqlboiler/v4 +go get github.com/volatiletech/sqlboiler-sqlite3 + +# run the migrations (creates testrun/TestSimple/roomdb) +go test -run Simple + +# make sure the sqlite file was created +test -f testrun/TestSimple/roomdb || { + echo 'roomdb file missing' + exit 1 +} + +# generate the models package sqlboiler sqlite3 --wipe --no-tests -echo "all done!" + +echo "all done. models updated!" diff --git a/admindb/sqlite/generated.db b/admindb/sqlite/generated.db deleted file mode 100644 index 349dfdd..0000000 Binary files a/admindb/sqlite/generated.db and /dev/null differ diff --git a/admindb/sqlite/invites.go b/admindb/sqlite/invites.go new file mode 100644 index 0000000..d9fe7cd --- /dev/null +++ b/admindb/sqlite/invites.go @@ -0,0 +1,207 @@ +package sqlite + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "fmt" + + "github.com/friendsofgo/errors" + "github.com/mattn/go-sqlite3" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries/qm" + + "github.com/ssb-ngi-pointer/go-ssb-room/admindb" + "github.com/ssb-ngi-pointer/go-ssb-room/admindb/sqlite/models" + refs "go.mindeco.de/ssb-refs" +) + +// compiler assertion to ensure the struct fullfills the interface +var _ admindb.InviteService = (*Invites)(nil) + +// Invites implements the admindb.InviteService. +// Tokens are stored as sha256 hashes on disk to protect against attackers gaining database read-access. +type Invites struct { + db *sql.DB + + allowList *AllowList +} + +const tokenLength = 50 + +// Create creates a new invite for a new member. It returns the token or an error. +// createdBy is user ID of the admin or moderator who created it. +// aliasSuggestion is optional (empty string is fine) but can be used to disambiguate open invites. (See https://github.com/ssb-ngi-pointer/rooms2/issues/21) +// The returned token is base64 URL encoded and has tokenLength when decoded. +func (i Invites) Create(ctx context.Context, createdBy int64, aliasSuggestion string) (string, error) { + var newInvite = models.Invite{ + CreatedBy: createdBy, + AliasSuggestion: aliasSuggestion, + } + + tokenBytes := make([]byte, tokenLength) + + err := transact(i.db, func(tx *sql.Tx) error { + + inserted := false + trying: + for tries := 100; tries > 0; tries-- { + // generate an invite code + rand.Read(tokenBytes) + + // hash the binary of the token for storage + h := sha256.New() + h.Write(tokenBytes) + newInvite.Token = fmt.Sprintf("%x", h.Sum(nil)) + + // insert the new invite + err := newInvite.Insert(ctx, tx, boil.Infer()) + if err != nil { + var sqlErr sqlite3.Error + if errors.As(err, &sqlErr) && sqlErr.ExtendedCode == sqlite3.ErrConstraintUnique { + // generated an existing token, retry + continue trying + } + return err + } + inserted = true + break // no error means it worked! + } + + if !inserted { + return errors.New("admindb: failed to generate an invite token in a reasonable amount of time") + } + + return nil + }) + + if err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(tokenBytes), nil +} + +// Consume checks if the passed token is still valid. If it is it adds newMember to the members of the room and invalidates the token. +// If the token isn't valid, it returns an error. +// Tokens need to be base64 URL encoded and when decoded be of tokenLength. +func (i Invites) Consume(ctx context.Context, token string, newMember refs.FeedRef) (admindb.Invite, error) { + var inv admindb.Invite + + tokenBytes, err := base64.URLEncoding.DecodeString(token) + if err != nil { + return inv, err + } + + if n := len(tokenBytes); n != tokenLength { + return inv, fmt.Errorf("admindb: invalid invite token length (only got %d bytes)", n) + } + + // hash the binary of the passed token + h := sha256.New() + h.Write(tokenBytes) + hashedToken := fmt.Sprintf("%x", h.Sum(nil)) + + err = transact(i.db, func(tx *sql.Tx) error { + entry, err := models.Invites( + qm.Where("active = true AND token = ?", hashedToken), + qm.Load("CreatedByAuthFallback"), + ).One(ctx, tx) + if err != nil { + return err + } + + err = i.allowList.add(ctx, tx, newMember) + if err != nil { + return err + } + + // invalidate the invite for consumption + entry.Active = false + _, err = entry.Update(ctx, tx, boil.Whitelist("active")) + if err != nil { + return err + } + + inv.ID = entry.ID + inv.AliasSuggestion = entry.AliasSuggestion + inv.CreatedBy.ID = entry.R.CreatedByAuthFallback.ID + inv.CreatedBy.Name = entry.R.CreatedByAuthFallback.Name + + return nil + }) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return inv, admindb.ErrNotFound + } + return inv, err + } + + return inv, nil +} + +// since invites are marked as inavalid so that the code can't be generated twice, +// they need to be deleted periodically. +func deleteConsumedInvites(tx boil.ContextExecutor) error { + _, err := models.Invites(qm.Where("active = false")).DeleteAll(context.Background(), tx) + if err != nil { + return fmt.Errorf("admindb: failed to delete used invites: %w", err) + } + return nil +} + +// List returns a list of all the valid invites +func (i Invites) List(ctx context.Context) ([]admindb.Invite, error) { + var invs []admindb.Invite + + err := transact(i.db, func(tx *sql.Tx) error { + entries, err := models.Invites( + qm.Where("active = true"), + qm.Load("CreatedByAuthFallback"), + ).All(ctx, tx) + if err != nil { + return err + } + + invs = make([]admindb.Invite, len(entries)) + for i, e := range entries { + var inv admindb.Invite + inv.ID = e.ID + inv.AliasSuggestion = e.AliasSuggestion + inv.CreatedBy.ID = e.R.CreatedByAuthFallback.ID + inv.CreatedBy.Name = e.R.CreatedByAuthFallback.Name + + invs[i] = inv + } + + return nil + }) + if err != nil { + return nil, err + } + + return invs, nil +} + +// Revoke removes a active invite and invalidates it for future use. +func (i Invites) Revoke(ctx context.Context, id int64) error { + return transact(i.db, func(tx *sql.Tx) error { + entry, err := models.Invites( + qm.Where("active = true AND id = ?", id), + ).One(ctx, tx) + if err != nil { + return err + } + + entry.Active = false + _, err = entry.Update(ctx, tx, boil.Whitelist("active")) + if err != nil { + return err + } + + return nil + }) +} diff --git a/admindb/sqlite/invites_test.go b/admindb/sqlite/invites_test.go new file mode 100644 index 0000000..c4adfd6 --- /dev/null +++ b/admindb/sqlite/invites_test.go @@ -0,0 +1,110 @@ +package sqlite + +import ( + "bytes" + "context" + "encoding/base64" + "math/rand" + "os" + "path/filepath" + "testing" + + "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" + "github.com/stretchr/testify/require" + refs "go.mindeco.de/ssb-refs" +) + +func TestInvites(t *testing.T) { + ctx := context.Background() + + testRepo := filepath.Join("testrun", t.Name()) + os.RemoveAll(testRepo) + tr := repo.New(testRepo) + + // fake feed for testing, looks ok at least + newMember := refs.FeedRef{ID: bytes.Repeat([]byte("acab"), 8), Algo: refs.RefAlgoFeedSSB1} + + db, err := Open(tr) + require.NoError(t, err) + + t.Run("try to consume invalid token", func(t *testing.T) { + r := require.New(t) + + lst, err := db.Invites.List(ctx) + r.NoError(err, "failed to get empty list of tokens") + r.Len(lst, 0, "expected no active invites") + + randToken := make([]byte, 32) + rand.Read(randToken) + + _, err = db.Invites.Consume(ctx, string(randToken), newMember) + r.Error(err, "expected error for inactive invite") + }) + + t.Run("user needs to exist", func(t *testing.T) { + r := require.New(t) + + _, err := db.Invites.Create(ctx, 666, "") + r.Error(err, "can't create invite for invalid user") + }) + + testUserName := "test-user" + uid, err := db.AuthFallback.Create(ctx, testUserName, []byte("bad-password")) + require.NoError(t, err, "failed to create test user") + + t.Run("simple create and consume", func(t *testing.T) { + r := require.New(t) + + tok, err := db.Invites.Create(ctx, uid, "bestie") + r.NoError(err, "failed to create invite token") + + _, err = base64.URLEncoding.DecodeString(tok) + r.NoError(err, "not a valid base64 string") + + lst, err := db.Invites.List(ctx) + r.NoError(err, "failed to get list of tokens") + r.Len(lst, 1, "expected 1 invite") + + r.Equal("bestie", lst[0].AliasSuggestion) + r.Equal(testUserName, lst[0].CreatedBy.Name) + + inv, err := db.Invites.Consume(ctx, tok, newMember) + r.NoError(err, "failed to consume the invite") + r.Equal(testUserName, inv.CreatedBy.Name) + r.NotEqualValues(0, inv.ID, "invite ID unset") + + lst, err = db.Invites.List(ctx) + r.NoError(err, "failed to get list of tokens post consume") + r.Len(lst, 0, "expected no active invites") + + // can't use twice + _, err = db.Invites.Consume(ctx, tok, newMember) + r.Error(err, "failed to consume the invite") + }) + + t.Run("simple create but revoke before use", func(t *testing.T) { + r := require.New(t) + + tok, err := db.Invites.Create(ctx, uid, "bestie") + r.NoError(err, "failed to create invite token") + + lst, err := db.Invites.List(ctx) + r.NoError(err, "failed to get list of tokens") + r.Len(lst, 1, "expected 1 invite") + + r.Equal("bestie", lst[0].AliasSuggestion) + r.Equal(testUserName, lst[0].CreatedBy.Name) + + err = db.Invites.Revoke(ctx, lst[0].ID) + r.NoError(err, "failed to consume the invite") + + lst, err = db.Invites.List(ctx) + r.NoError(err, "failed to get list of tokens post consume") + r.Len(lst, 0, "expected no active invites") + + // can't use twice + _, err = db.Invites.Consume(ctx, tok, newMember) + r.Error(err, "failed to consume the invite") + }) + +} diff --git a/admindb/sqlite/migrations/4-invites.sql b/admindb/sqlite/migrations/4-invites.sql new file mode 100644 index 0000000..45edf41 --- /dev/null +++ b/admindb/sqlite/migrations/4-invites.sql @@ -0,0 +1,22 @@ +-- +migrate Up +CREATE TABLE invites ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + token text UNIQUE NOT NULL, + created_by integer NOT NULL, + alias_suggestion text NOT NULL DEFAULT "", -- optional + active boolean NOT NULL DEFAULT true, + + -- TODO: replace auth_fallback with a user table once we do "sign in with ssb" + FOREIGN KEY ( created_by ) REFERENCES auth_fallback( "id" ) +); + +CREATE INDEX invite_active_ids ON invites(id) WHERE active=true; +CREATE UNIQUE INDEX invite_active_tokens ON invites(token) WHERE active=true; +CREATE INDEX invite_inactive ON invites(active); + +-- +migrate Down +DROP TABLE invites; + +DROP INDEX invite_active_ids; +DROP INDEX invite_active_tokens; +DROP INDEX invite_inactive; diff --git a/admindb/sqlite/models/auth_fallback.go b/admindb/sqlite/models/auth_fallback.go index 82bdd7e..f94f7a7 100644 --- a/admindb/sqlite/models/auth_fallback.go +++ b/admindb/sqlite/models/auth_fallback.go @@ -86,10 +86,14 @@ var AuthFallbackWhere = struct { // AuthFallbackRels is where relationship names are stored. var AuthFallbackRels = struct { -}{} + CreatedByInvites string +}{ + CreatedByInvites: "CreatedByInvites", +} // authFallbackR is where relationships are stored. type authFallbackR struct { + CreatedByInvites InviteSlice `boil:"CreatedByInvites" json:"CreatedByInvites" toml:"CreatedByInvites" yaml:"CreatedByInvites"` } // NewStruct creates a new relationship struct @@ -382,6 +386,178 @@ func (q authFallbackQuery) Exists(ctx context.Context, exec boil.ContextExecutor return count > 0, nil } +// CreatedByInvites retrieves all the invite's Invites with an executor via created_by column. +func (o *AuthFallback) CreatedByInvites(mods ...qm.QueryMod) inviteQuery { + var queryMods []qm.QueryMod + if len(mods) != 0 { + queryMods = append(queryMods, mods...) + } + + queryMods = append(queryMods, + qm.Where("\"invites\".\"created_by\"=?", o.ID), + ) + + query := Invites(queryMods...) + queries.SetFrom(query.Query, "\"invites\"") + + if len(queries.GetSelect(query.Query)) == 0 { + queries.SetSelect(query.Query, []string{"\"invites\".*"}) + } + + return query +} + +// LoadCreatedByInvites allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for a 1-M or N-M relationship. +func (authFallbackL) LoadCreatedByInvites(ctx context.Context, e boil.ContextExecutor, singular bool, maybeAuthFallback interface{}, mods queries.Applicator) error { + var slice []*AuthFallback + var object *AuthFallback + + if singular { + object = maybeAuthFallback.(*AuthFallback) + } else { + slice = *maybeAuthFallback.(*[]*AuthFallback) + } + + args := make([]interface{}, 0, 1) + if singular { + if object.R == nil { + object.R = &authFallbackR{} + } + args = append(args, object.ID) + } else { + Outer: + for _, obj := range slice { + if obj.R == nil { + obj.R = &authFallbackR{} + } + + for _, a := range args { + if a == obj.ID { + continue Outer + } + } + + args = append(args, obj.ID) + } + } + + if len(args) == 0 { + return nil + } + + query := NewQuery( + qm.From(`invites`), + qm.WhereIn(`invites.created_by in ?`, args...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load invites") + } + + var resultSlice []*Invite + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice invites") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results in eager load on invites") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for invites") + } + + if len(inviteAfterSelectHooks) != 0 { + for _, obj := range resultSlice { + if err := obj.doAfterSelectHooks(ctx, e); err != nil { + return err + } + } + } + if singular { + object.R.CreatedByInvites = resultSlice + for _, foreign := range resultSlice { + if foreign.R == nil { + foreign.R = &inviteR{} + } + foreign.R.CreatedByAuthFallback = object + } + return nil + } + + for _, foreign := range resultSlice { + for _, local := range slice { + if local.ID == foreign.CreatedBy { + local.R.CreatedByInvites = append(local.R.CreatedByInvites, foreign) + if foreign.R == nil { + foreign.R = &inviteR{} + } + foreign.R.CreatedByAuthFallback = local + break + } + } + } + + return nil +} + +// AddCreatedByInvites adds the given related objects to the existing relationships +// of the auth_fallback, optionally inserting them as new records. +// Appends related to o.R.CreatedByInvites. +// Sets related.R.CreatedByAuthFallback appropriately. +func (o *AuthFallback) AddCreatedByInvites(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*Invite) error { + var err error + for _, rel := range related { + if insert { + rel.CreatedBy = o.ID + if err = rel.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } else { + updateQuery := fmt.Sprintf( + "UPDATE \"invites\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 0, []string{"created_by"}), + strmangle.WhereClause("\"", "\"", 0, invitePrimaryKeyColumns), + ) + values := []interface{}{o.ID, rel.ID} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update foreign table") + } + + rel.CreatedBy = o.ID + } + } + + if o.R == nil { + o.R = &authFallbackR{ + CreatedByInvites: related, + } + } else { + o.R.CreatedByInvites = append(o.R.CreatedByInvites, related...) + } + + for _, rel := range related { + if rel.R == nil { + rel.R = &inviteR{ + CreatedByAuthFallback: o, + } + } else { + rel.R.CreatedByAuthFallback = o + } + } + return nil +} + // AuthFallbacks retrieves all the records using an executor. func AuthFallbacks(mods ...qm.QueryMod) authFallbackQuery { mods = append(mods, qm.From("\"auth_fallback\"")) diff --git a/admindb/sqlite/models/boil_table_names.go b/admindb/sqlite/models/boil_table_names.go index 9e18c0c..a68235d 100644 --- a/admindb/sqlite/models/boil_table_names.go +++ b/admindb/sqlite/models/boil_table_names.go @@ -6,12 +6,14 @@ package models var TableNames = struct { AllowList string AuthFallback string + Invites string Notices string PinNotices string Pins string }{ AllowList: "allow_list", AuthFallback: "auth_fallback", + Invites: "invites", Notices: "notices", PinNotices: "pin_notices", Pins: "pins", diff --git a/admindb/sqlite/models/invites.go b/admindb/sqlite/models/invites.go new file mode 100644 index 0000000..a3e1c3e --- /dev/null +++ b/admindb/sqlite/models/invites.go @@ -0,0 +1,972 @@ +// Code generated by SQLBoiler 4.4.0 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT. +// This file is meant to be re-generated in place and/or deleted at any time. + +package models + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strings" + "sync" + "time" + + "github.com/friendsofgo/errors" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries" + "github.com/volatiletech/sqlboiler/v4/queries/qm" + "github.com/volatiletech/sqlboiler/v4/queries/qmhelper" + "github.com/volatiletech/strmangle" +) + +// Invite is an object representing the database table. +type Invite struct { + ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"` + Token string `boil:"token" json:"token" toml:"token" yaml:"token"` + CreatedBy int64 `boil:"created_by" json:"created_by" toml:"created_by" yaml:"created_by"` + AliasSuggestion string `boil:"alias_suggestion" json:"alias_suggestion" toml:"alias_suggestion" yaml:"alias_suggestion"` + Active bool `boil:"active" json:"active" toml:"active" yaml:"active"` + + R *inviteR `boil:"-" json:"-" toml:"-" yaml:"-"` + L inviteL `boil:"-" json:"-" toml:"-" yaml:"-"` +} + +var InviteColumns = struct { + ID string + Token string + CreatedBy string + AliasSuggestion string + Active string +}{ + ID: "id", + Token: "token", + CreatedBy: "created_by", + AliasSuggestion: "alias_suggestion", + Active: "active", +} + +// Generated where + +type whereHelperbool struct{ field string } + +func (w whereHelperbool) EQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) } +func (w whereHelperbool) NEQ(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) } +func (w whereHelperbool) LT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) } +func (w whereHelperbool) LTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) } +func (w whereHelperbool) GT(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) } +func (w whereHelperbool) GTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } + +var InviteWhere = struct { + ID whereHelperint64 + Token whereHelperstring + CreatedBy whereHelperint64 + AliasSuggestion whereHelperstring + Active whereHelperbool +}{ + ID: whereHelperint64{field: "\"invites\".\"id\""}, + Token: whereHelperstring{field: "\"invites\".\"token\""}, + CreatedBy: whereHelperint64{field: "\"invites\".\"created_by\""}, + AliasSuggestion: whereHelperstring{field: "\"invites\".\"alias_suggestion\""}, + Active: whereHelperbool{field: "\"invites\".\"active\""}, +} + +// InviteRels is where relationship names are stored. +var InviteRels = struct { + CreatedByAuthFallback string +}{ + CreatedByAuthFallback: "CreatedByAuthFallback", +} + +// inviteR is where relationships are stored. +type inviteR struct { + CreatedByAuthFallback *AuthFallback `boil:"CreatedByAuthFallback" json:"CreatedByAuthFallback" toml:"CreatedByAuthFallback" yaml:"CreatedByAuthFallback"` +} + +// NewStruct creates a new relationship struct +func (*inviteR) NewStruct() *inviteR { + return &inviteR{} +} + +// inviteL is where Load methods for each relationship are stored. +type inviteL struct{} + +var ( + inviteAllColumns = []string{"id", "token", "created_by", "alias_suggestion", "active"} + inviteColumnsWithoutDefault = []string{} + inviteColumnsWithDefault = []string{"id", "token", "created_by", "alias_suggestion", "active"} + invitePrimaryKeyColumns = []string{"id"} +) + +type ( + // InviteSlice is an alias for a slice of pointers to Invite. + // This should generally be used opposed to []Invite. + InviteSlice []*Invite + // InviteHook is the signature for custom Invite hook methods + InviteHook func(context.Context, boil.ContextExecutor, *Invite) error + + inviteQuery struct { + *queries.Query + } +) + +// Cache for insert, update and upsert +var ( + inviteType = reflect.TypeOf(&Invite{}) + inviteMapping = queries.MakeStructMapping(inviteType) + invitePrimaryKeyMapping, _ = queries.BindMapping(inviteType, inviteMapping, invitePrimaryKeyColumns) + inviteInsertCacheMut sync.RWMutex + inviteInsertCache = make(map[string]insertCache) + inviteUpdateCacheMut sync.RWMutex + inviteUpdateCache = make(map[string]updateCache) + inviteUpsertCacheMut sync.RWMutex + inviteUpsertCache = make(map[string]insertCache) +) + +var ( + // Force time package dependency for automated UpdatedAt/CreatedAt. + _ = time.Second + // Force qmhelper dependency for where clause generation (which doesn't + // always happen) + _ = qmhelper.Where +) + +var inviteBeforeInsertHooks []InviteHook +var inviteBeforeUpdateHooks []InviteHook +var inviteBeforeDeleteHooks []InviteHook +var inviteBeforeUpsertHooks []InviteHook + +var inviteAfterInsertHooks []InviteHook +var inviteAfterSelectHooks []InviteHook +var inviteAfterUpdateHooks []InviteHook +var inviteAfterDeleteHooks []InviteHook +var inviteAfterUpsertHooks []InviteHook + +// doBeforeInsertHooks executes all "before insert" hooks. +func (o *Invite) doBeforeInsertHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteBeforeInsertHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doBeforeUpdateHooks executes all "before Update" hooks. +func (o *Invite) doBeforeUpdateHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteBeforeUpdateHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doBeforeDeleteHooks executes all "before Delete" hooks. +func (o *Invite) doBeforeDeleteHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteBeforeDeleteHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doBeforeUpsertHooks executes all "before Upsert" hooks. +func (o *Invite) doBeforeUpsertHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteBeforeUpsertHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterInsertHooks executes all "after Insert" hooks. +func (o *Invite) doAfterInsertHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteAfterInsertHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterSelectHooks executes all "after Select" hooks. +func (o *Invite) doAfterSelectHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteAfterSelectHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterUpdateHooks executes all "after Update" hooks. +func (o *Invite) doAfterUpdateHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteAfterUpdateHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterDeleteHooks executes all "after Delete" hooks. +func (o *Invite) doAfterDeleteHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteAfterDeleteHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// doAfterUpsertHooks executes all "after Upsert" hooks. +func (o *Invite) doAfterUpsertHooks(ctx context.Context, exec boil.ContextExecutor) (err error) { + if boil.HooksAreSkipped(ctx) { + return nil + } + + for _, hook := range inviteAfterUpsertHooks { + if err := hook(ctx, exec, o); err != nil { + return err + } + } + + return nil +} + +// AddInviteHook registers your hook function for all future operations. +func AddInviteHook(hookPoint boil.HookPoint, inviteHook InviteHook) { + switch hookPoint { + case boil.BeforeInsertHook: + inviteBeforeInsertHooks = append(inviteBeforeInsertHooks, inviteHook) + case boil.BeforeUpdateHook: + inviteBeforeUpdateHooks = append(inviteBeforeUpdateHooks, inviteHook) + case boil.BeforeDeleteHook: + inviteBeforeDeleteHooks = append(inviteBeforeDeleteHooks, inviteHook) + case boil.BeforeUpsertHook: + inviteBeforeUpsertHooks = append(inviteBeforeUpsertHooks, inviteHook) + case boil.AfterInsertHook: + inviteAfterInsertHooks = append(inviteAfterInsertHooks, inviteHook) + case boil.AfterSelectHook: + inviteAfterSelectHooks = append(inviteAfterSelectHooks, inviteHook) + case boil.AfterUpdateHook: + inviteAfterUpdateHooks = append(inviteAfterUpdateHooks, inviteHook) + case boil.AfterDeleteHook: + inviteAfterDeleteHooks = append(inviteAfterDeleteHooks, inviteHook) + case boil.AfterUpsertHook: + inviteAfterUpsertHooks = append(inviteAfterUpsertHooks, inviteHook) + } +} + +// One returns a single invite record from the query. +func (q inviteQuery) One(ctx context.Context, exec boil.ContextExecutor) (*Invite, error) { + o := &Invite{} + + queries.SetLimit(q.Query, 1) + + err := q.Bind(ctx, exec, o) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: failed to execute a one query for invites") + } + + if err := o.doAfterSelectHooks(ctx, exec); err != nil { + return o, err + } + + return o, nil +} + +// All returns all Invite records from the query. +func (q inviteQuery) All(ctx context.Context, exec boil.ContextExecutor) (InviteSlice, error) { + var o []*Invite + + err := q.Bind(ctx, exec, &o) + if err != nil { + return nil, errors.Wrap(err, "models: failed to assign all query results to Invite slice") + } + + if len(inviteAfterSelectHooks) != 0 { + for _, obj := range o { + if err := obj.doAfterSelectHooks(ctx, exec); err != nil { + return o, err + } + } + } + + return o, nil +} + +// Count returns the count of all Invite records in the query. +func (q inviteQuery) Count(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, "models: failed to count invites rows") + } + + return count, nil +} + +// Exists checks if the row exists in the table. +func (q inviteQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (bool, error) { + var count int64 + + queries.SetSelect(q.Query, nil) + queries.SetCount(q.Query) + queries.SetLimit(q.Query, 1) + + err := q.Query.QueryRowContext(ctx, exec).Scan(&count) + if err != nil { + return false, errors.Wrap(err, "models: failed to check if invites exists") + } + + return count > 0, nil +} + +// CreatedByAuthFallback pointed to by the foreign key. +func (o *Invite) CreatedByAuthFallback(mods ...qm.QueryMod) authFallbackQuery { + queryMods := []qm.QueryMod{ + qm.Where("\"id\" = ?", o.CreatedBy), + } + + queryMods = append(queryMods, mods...) + + query := AuthFallbacks(queryMods...) + queries.SetFrom(query.Query, "\"auth_fallback\"") + + return query +} + +// LoadCreatedByAuthFallback allows an eager lookup of values, cached into the +// loaded structs of the objects. This is for an N-1 relationship. +func (inviteL) LoadCreatedByAuthFallback(ctx context.Context, e boil.ContextExecutor, singular bool, maybeInvite interface{}, mods queries.Applicator) error { + var slice []*Invite + var object *Invite + + if singular { + object = maybeInvite.(*Invite) + } else { + slice = *maybeInvite.(*[]*Invite) + } + + args := make([]interface{}, 0, 1) + if singular { + if object.R == nil { + object.R = &inviteR{} + } + args = append(args, object.CreatedBy) + + } else { + Outer: + for _, obj := range slice { + if obj.R == nil { + obj.R = &inviteR{} + } + + for _, a := range args { + if a == obj.CreatedBy { + continue Outer + } + } + + args = append(args, obj.CreatedBy) + + } + } + + if len(args) == 0 { + return nil + } + + query := NewQuery( + qm.From(`auth_fallback`), + qm.WhereIn(`auth_fallback.id in ?`, args...), + ) + if mods != nil { + mods.Apply(query) + } + + results, err := query.QueryContext(ctx, e) + if err != nil { + return errors.Wrap(err, "failed to eager load AuthFallback") + } + + var resultSlice []*AuthFallback + if err = queries.Bind(results, &resultSlice); err != nil { + return errors.Wrap(err, "failed to bind eager loaded slice AuthFallback") + } + + if err = results.Close(); err != nil { + return errors.Wrap(err, "failed to close results of eager load for auth_fallback") + } + if err = results.Err(); err != nil { + return errors.Wrap(err, "error occurred during iteration of eager loaded relations for auth_fallback") + } + + if len(inviteAfterSelectHooks) != 0 { + for _, obj := range resultSlice { + if err := obj.doAfterSelectHooks(ctx, e); err != nil { + return err + } + } + } + + if len(resultSlice) == 0 { + return nil + } + + if singular { + foreign := resultSlice[0] + object.R.CreatedByAuthFallback = foreign + if foreign.R == nil { + foreign.R = &authFallbackR{} + } + foreign.R.CreatedByInvites = append(foreign.R.CreatedByInvites, object) + return nil + } + + for _, local := range slice { + for _, foreign := range resultSlice { + if local.CreatedBy == foreign.ID { + local.R.CreatedByAuthFallback = foreign + if foreign.R == nil { + foreign.R = &authFallbackR{} + } + foreign.R.CreatedByInvites = append(foreign.R.CreatedByInvites, local) + break + } + } + } + + return nil +} + +// SetCreatedByAuthFallback of the invite to the related item. +// Sets o.R.CreatedByAuthFallback to related. +// Adds o to related.R.CreatedByInvites. +func (o *Invite) SetCreatedByAuthFallback(ctx context.Context, exec boil.ContextExecutor, insert bool, related *AuthFallback) error { + var err error + if insert { + if err = related.Insert(ctx, exec, boil.Infer()); err != nil { + return errors.Wrap(err, "failed to insert into foreign table") + } + } + + updateQuery := fmt.Sprintf( + "UPDATE \"invites\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 0, []string{"created_by"}), + strmangle.WhereClause("\"", "\"", 0, invitePrimaryKeyColumns), + ) + values := []interface{}{related.ID, o.ID} + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, updateQuery) + fmt.Fprintln(writer, values) + } + if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil { + return errors.Wrap(err, "failed to update local table") + } + + o.CreatedBy = related.ID + if o.R == nil { + o.R = &inviteR{ + CreatedByAuthFallback: related, + } + } else { + o.R.CreatedByAuthFallback = related + } + + if related.R == nil { + related.R = &authFallbackR{ + CreatedByInvites: InviteSlice{o}, + } + } else { + related.R.CreatedByInvites = append(related.R.CreatedByInvites, o) + } + + return nil +} + +// Invites retrieves all the records using an executor. +func Invites(mods ...qm.QueryMod) inviteQuery { + mods = append(mods, qm.From("\"invites\"")) + return inviteQuery{NewQuery(mods...)} +} + +// FindInvite retrieves a single record by ID with an executor. +// If selectCols is empty Find will return all columns. +func FindInvite(ctx context.Context, exec boil.ContextExecutor, iD int64, selectCols ...string) (*Invite, error) { + inviteObj := &Invite{} + + sel := "*" + if len(selectCols) > 0 { + sel = strings.Join(strmangle.IdentQuoteSlice(dialect.LQ, dialect.RQ, selectCols), ",") + } + query := fmt.Sprintf( + "select %s from \"invites\" where \"id\"=?", sel, + ) + + q := queries.Raw(query, iD) + + err := q.Bind(ctx, exec, inviteObj) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + return nil, errors.Wrap(err, "models: unable to select from invites") + } + + return inviteObj, nil +} + +// Insert a single record using an executor. +// See boil.Columns.InsertColumnSet documentation to understand column list inference for inserts. +func (o *Invite) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error { + if o == nil { + return errors.New("models: no invites provided for insertion") + } + + var err error + + if err := o.doBeforeInsertHooks(ctx, exec); err != nil { + return err + } + + nzDefaults := queries.NonZeroDefaultSet(inviteColumnsWithDefault, o) + + key := makeCacheKey(columns, nzDefaults) + inviteInsertCacheMut.RLock() + cache, cached := inviteInsertCache[key] + inviteInsertCacheMut.RUnlock() + + if !cached { + wl, returnColumns := columns.InsertColumnSet( + inviteAllColumns, + inviteColumnsWithDefault, + inviteColumnsWithoutDefault, + nzDefaults, + ) + + cache.valueMapping, err = queries.BindMapping(inviteType, inviteMapping, wl) + if err != nil { + return err + } + cache.retMapping, err = queries.BindMapping(inviteType, inviteMapping, returnColumns) + if err != nil { + return err + } + if len(wl) != 0 { + cache.query = fmt.Sprintf("INSERT INTO \"invites\" (\"%s\") %%sVALUES (%s)%%s", strings.Join(wl, "\",\""), strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), 1, 1)) + } else { + cache.query = "INSERT INTO \"invites\" %sDEFAULT VALUES%s" + } + + var queryOutput, queryReturning string + + if len(cache.retMapping) != 0 { + cache.retQuery = fmt.Sprintf("SELECT \"%s\" FROM \"invites\" WHERE %s", strings.Join(returnColumns, "\",\""), strmangle.WhereClause("\"", "\"", 0, invitePrimaryKeyColumns)) + } + + cache.query = fmt.Sprintf(cache.query, queryOutput, queryReturning) + } + + value := reflect.Indirect(reflect.ValueOf(o)) + vals := queries.ValuesFromMapping(value, cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, vals) + } + result, err := exec.ExecContext(ctx, cache.query, vals...) + + if err != nil { + return errors.Wrap(err, "models: unable to insert into invites") + } + + var lastID int64 + var identifierCols []interface{} + + if len(cache.retMapping) == 0 { + goto CacheNoHooks + } + + lastID, err = result.LastInsertId() + if err != nil { + return ErrSyncFail + } + + o.ID = int64(lastID) + if lastID != 0 && len(cache.retMapping) == 1 && cache.retMapping[0] == inviteMapping["id"] { + goto CacheNoHooks + } + + identifierCols = []interface{}{ + o.ID, + } + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.retQuery) + fmt.Fprintln(writer, identifierCols...) + } + err = exec.QueryRowContext(ctx, cache.retQuery, identifierCols...).Scan(queries.PtrsFromMapping(value, cache.retMapping)...) + if err != nil { + return errors.Wrap(err, "models: unable to populate default values for invites") + } + +CacheNoHooks: + if !cached { + inviteInsertCacheMut.Lock() + inviteInsertCache[key] = cache + inviteInsertCacheMut.Unlock() + } + + return o.doAfterInsertHooks(ctx, exec) +} + +// Update uses an executor to update the Invite. +// See boil.Columns.UpdateColumnSet documentation to understand column list inference for updates. +// Update does not automatically update the record in case of default values. Use .Reload() to refresh the records. +func (o *Invite) Update(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) (int64, error) { + var err error + if err = o.doBeforeUpdateHooks(ctx, exec); err != nil { + return 0, err + } + key := makeCacheKey(columns, nil) + inviteUpdateCacheMut.RLock() + cache, cached := inviteUpdateCache[key] + inviteUpdateCacheMut.RUnlock() + + if !cached { + wl := columns.UpdateColumnSet( + inviteAllColumns, + invitePrimaryKeyColumns, + ) + + if !columns.IsWhitelist() { + wl = strmangle.SetComplement(wl, []string{"created_at"}) + } + if len(wl) == 0 { + return 0, errors.New("models: unable to update invites, could not build whitelist") + } + + cache.query = fmt.Sprintf("UPDATE \"invites\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 0, wl), + strmangle.WhereClause("\"", "\"", 0, invitePrimaryKeyColumns), + ) + cache.valueMapping, err = queries.BindMapping(inviteType, inviteMapping, append(wl, invitePrimaryKeyColumns...)) + if err != nil { + return 0, err + } + } + + values := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), cache.valueMapping) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, cache.query) + fmt.Fprintln(writer, values) + } + var result sql.Result + result, err = exec.ExecContext(ctx, cache.query, values...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update invites row") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by update for invites") + } + + if !cached { + inviteUpdateCacheMut.Lock() + inviteUpdateCache[key] = cache + inviteUpdateCacheMut.Unlock() + } + + return rowsAff, o.doAfterUpdateHooks(ctx, exec) +} + +// UpdateAll updates all rows with the specified column values. +func (q inviteQuery) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + queries.SetUpdate(q.Query, cols) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all for invites") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected for invites") + } + + return rowsAff, nil +} + +// UpdateAll updates all rows with the specified column values, using an executor. +func (o InviteSlice) UpdateAll(ctx context.Context, exec boil.ContextExecutor, cols M) (int64, error) { + ln := int64(len(o)) + if ln == 0 { + return 0, nil + } + + if len(cols) == 0 { + return 0, errors.New("models: update all requires at least one column argument") + } + + colNames := make([]string, len(cols)) + args := make([]interface{}, len(cols)) + + i := 0 + for name, value := range cols { + colNames[i] = name + args[i] = value + i++ + } + + // Append all of the primary key values for each column + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), invitePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := fmt.Sprintf("UPDATE \"invites\" SET %s WHERE %s", + strmangle.SetParamNames("\"", "\"", 0, colNames), + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 0, invitePrimaryKeyColumns, len(o))) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to update all in invite slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: unable to retrieve rows affected all in update all invite") + } + return rowsAff, nil +} + +// Delete deletes a single Invite record with an executor. +// Delete will match against the primary key column to find the record to delete. +func (o *Invite) Delete(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if o == nil { + return 0, errors.New("models: no Invite provided for delete") + } + + if err := o.doBeforeDeleteHooks(ctx, exec); err != nil { + return 0, err + } + + args := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(o)), invitePrimaryKeyMapping) + sql := "DELETE FROM \"invites\" WHERE \"id\"=?" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args...) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete from invites") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by delete for invites") + } + + if err := o.doAfterDeleteHooks(ctx, exec); err != nil { + return 0, err + } + + return rowsAff, nil +} + +// DeleteAll deletes all matching rows. +func (q inviteQuery) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if q.Query == nil { + return 0, errors.New("models: no inviteQuery provided for delete all") + } + + queries.SetDelete(q.Query) + + result, err := q.Query.ExecContext(ctx, exec) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from invites") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for invites") + } + + return rowsAff, nil +} + +// DeleteAll deletes all rows in the slice, using an executor. +func (o InviteSlice) DeleteAll(ctx context.Context, exec boil.ContextExecutor) (int64, error) { + if len(o) == 0 { + return 0, nil + } + + if len(inviteBeforeDeleteHooks) != 0 { + for _, obj := range o { + if err := obj.doBeforeDeleteHooks(ctx, exec); err != nil { + return 0, err + } + } + } + + var args []interface{} + for _, obj := range o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), invitePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "DELETE FROM \"invites\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 0, invitePrimaryKeyColumns, len(o)) + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, args) + } + result, err := exec.ExecContext(ctx, sql, args...) + if err != nil { + return 0, errors.Wrap(err, "models: unable to delete all from invite slice") + } + + rowsAff, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrap(err, "models: failed to get rows affected by deleteall for invites") + } + + if len(inviteAfterDeleteHooks) != 0 { + for _, obj := range o { + if err := obj.doAfterDeleteHooks(ctx, exec); err != nil { + return 0, err + } + } + } + + return rowsAff, nil +} + +// Reload refetches the object from the database +// using the primary keys with an executor. +func (o *Invite) Reload(ctx context.Context, exec boil.ContextExecutor) error { + ret, err := FindInvite(ctx, exec, o.ID) + if err != nil { + return err + } + + *o = *ret + return nil +} + +// ReloadAll refetches every row with matching primary key column values +// and overwrites the original object slice with the newly updated slice. +func (o *InviteSlice) ReloadAll(ctx context.Context, exec boil.ContextExecutor) error { + if o == nil || len(*o) == 0 { + return nil + } + + slice := InviteSlice{} + var args []interface{} + for _, obj := range *o { + pkeyArgs := queries.ValuesFromMapping(reflect.Indirect(reflect.ValueOf(obj)), invitePrimaryKeyMapping) + args = append(args, pkeyArgs...) + } + + sql := "SELECT \"invites\".* FROM \"invites\" WHERE " + + strmangle.WhereClauseRepeated(string(dialect.LQ), string(dialect.RQ), 0, invitePrimaryKeyColumns, len(*o)) + + q := queries.Raw(sql, args...) + + err := q.Bind(ctx, exec, &slice) + if err != nil { + return errors.Wrap(err, "models: unable to reload all in InviteSlice") + } + + *o = slice + + return nil +} + +// InviteExists checks if the Invite row exists. +func InviteExists(ctx context.Context, exec boil.ContextExecutor, iD int64) (bool, error) { + var exists bool + sql := "select exists(select 1 from \"invites\" where \"id\"=? limit 1)" + + if boil.IsDebug(ctx) { + writer := boil.DebugWriterFrom(ctx) + fmt.Fprintln(writer, sql) + fmt.Fprintln(writer, iD) + } + row := exec.QueryRowContext(ctx, sql, iD) + + err := row.Scan(&exists) + if err != nil { + return false, errors.Wrap(err, "models: unable to check if invites exists") + } + + return exists, nil +} diff --git a/admindb/sqlite/new.go b/admindb/sqlite/new.go index 1148d2c..9609e4d 100644 --- a/admindb/sqlite/new.go +++ b/admindb/sqlite/new.go @@ -1,5 +1,19 @@ // SPDX-License-Identifier: MIT +// Package sqlite implements the SQLite backend of the admindb interfaces. +// +// It uses sql-migrate (github.com/rubenv/sql-migrate) for it's schema definition and maintainace. +// For query construction/ORM it uses SQLBoiler (https://github.com/volatiletech/sqlboiler). +// +// The process of updating the schema and ORM can be summarized as follows: +// +// 1. Make changes to the interfaces in package admindb +// 2. Add a new migration to the 'migrations' folder +// 3. Run 'go test -run Simple', which applies all the migrations +// 4. Run sqlboiler to generate package models +// 5. Implement the interface as needed by using the models package +// +// For convenience step 3 and 4 are combined in the generate_models bash script. package sqlite import ( @@ -8,6 +22,7 @@ import ( "log" "os" "path/filepath" + "time" migrate "github.com/rubenv/sql-migrate" @@ -26,6 +41,8 @@ type Database struct { PinnedNotices admindb.PinnedNoticesService Notices admindb.NoticesService + + Invites admindb.InviteService } // Open looks for a database file 'fname' @@ -39,6 +56,9 @@ func Open(r repo.Interface) (*Database, error) { } } + // enable constraint enforcment for relations + fname += "?_foreign_keys=on" + db, err := sql.Open("sqlite3", fname) if err != nil { return nil, fmt.Errorf("admindb: failed to open sqlite database: %w", err) @@ -57,14 +77,38 @@ func Open(r repo.Interface) (*Database, error) { log.Printf("admindb: applied %d migrations", n) } + if err := deleteConsumedInvites(db); err != nil { + return nil, err + } + + go func() { // server might not restart as often + threeDays := 5 * 24 * time.Hour + ticker := time.NewTicker(threeDays) + for range ticker.C { + err := transact(db, func(tx *sql.Tx) error { + return deleteConsumedInvites(tx) + }) + if err != nil { + // TODO: hook up logging + log.Printf("admindb: failed to clean up old invites: %s", err.Error()) + } + } + }() + + al := &AllowList{db} admindb := &Database{ db: db, AuthWithSSB: AuthWithSSB{db}, AuthFallback: AuthFallback{db}, - AllowList: AllowList{db}, + AllowList: al, Aliases: Aliases{db}, PinnedNotices: PinnedNotices{db}, Notices: Notices{db}, + + Invites: Invites{ + db: db, + allowList: al, + }, } return admindb, nil diff --git a/admindb/sqlite/new_test.go b/admindb/sqlite/new_test.go index 2efd1fb..bbac8c4 100644 --- a/admindb/sqlite/new_test.go +++ b/admindb/sqlite/new_test.go @@ -22,8 +22,9 @@ func TestSimple(t *testing.T) { require.NoError(t, err) ctx := context.Background() - err = db.AuthFallback.Create(ctx, "testUser", []byte("super-cheesy-password-12345")) + uid, err := db.AuthFallback.Create(ctx, "testUser", []byte("super-cheesy-password-12345")) require.NoError(t, err) + require.NotEqual(t, 0, uid) err = db.Close() require.NoError(t, err) diff --git a/admindb/sqlite/notices.go b/admindb/sqlite/notices.go index 4f02a7a..08d8567 100644 --- a/admindb/sqlite/notices.go +++ b/admindb/sqlite/notices.go @@ -13,7 +13,7 @@ import ( "github.com/ssb-ngi-pointer/go-ssb-room/admindb/sqlite/models" ) -// make sure to implement interfaces correctly +// compiler assertion to ensure the struct fullfills the interface var _ admindb.PinnedNoticesService = (*PinnedNotices)(nil) type PinnedNotices struct { @@ -112,7 +112,7 @@ func (pn PinnedNotices) Set(ctx context.Context, name admindb.PinnedNoticeName, return nil } -// make sure to implement interfaces correctly +// compiler assertion to ensure the struct fullfills the interface var _ admindb.NoticesService = (*Notices)(nil) type Notices struct { diff --git a/admindb/sqlite/roomcfg.go b/admindb/sqlite/roomcfg.go index a92ac47..1847701 100644 --- a/admindb/sqlite/roomcfg.go +++ b/admindb/sqlite/roomcfg.go @@ -17,7 +17,7 @@ import ( refs "go.mindeco.de/ssb-refs" ) -// make sure to implement interfaces correctly +// compiler assertion to ensure the struct fullfills the interface var _ admindb.AllowListService = (*AllowList)(nil) type AllowList struct { @@ -26,6 +26,14 @@ type AllowList struct { // Add adds the feed to the list. func (l AllowList) Add(ctx context.Context, a refs.FeedRef) error { + // single insert transaction but this makes it easier to re-use in invites.Consume + return transact(l.db, func(tx *sql.Tx) error { + return l.add(ctx, tx, a) + }) +} + +// this add is not exported and for internal use with transactions. +func (l AllowList) add(ctx context.Context, tx *sql.Tx, a refs.FeedRef) error { // TODO: better valid if _, err := refs.ParseFeedRef(a.Ref()); err != nil { return err @@ -34,7 +42,7 @@ func (l AllowList) Add(ctx context.Context, a refs.FeedRef) error { var entry models.AllowList entry.PubKey.FeedRef = a - err := entry.Insert(ctx, l.db, boil.Whitelist("pub_key")) + err := entry.Insert(ctx, tx, boil.Whitelist("pub_key")) if err != nil { var sqlErr sqlite3.Error if errors.As(err, &sqlErr) && sqlErr.ExtendedCode == sqlite3.ErrConstraintUnique { diff --git a/admindb/sqlite/roomcfg_test.go b/admindb/sqlite/roomcfg_test.go index 6f44eef..b6db956 100644 --- a/admindb/sqlite/roomcfg_test.go +++ b/admindb/sqlite/roomcfg_test.go @@ -36,7 +36,10 @@ func TestAllowList(t *testing.T) { err = db.AllowList.Add(ctx, okFeed) r.NoError(err) - count, err := models.AllowLists().Count(ctx, db.AllowList.(AllowList).db) + // hack into the interface to get the concrete database/sql instance + sqlDB := db.AllowList.(*AllowList).db + + count, err := models.AllowLists().Count(ctx, sqlDB) r.NoError(err) r.EqualValues(count, 1) @@ -53,7 +56,7 @@ func TestAllowList(t *testing.T) { err = db.AllowList.RemoveFeed(ctx, okFeed) r.NoError(err) - count, err = models.AllowLists().Count(ctx, db.AllowList.(AllowList).db) + count, err = models.AllowLists().Count(ctx, sqlDB) r.NoError(err) r.EqualValues(count, 0) diff --git a/admindb/types.go b/admindb/types.go index 573d9a7..faab751 100644 --- a/admindb/types.go +++ b/admindb/types.go @@ -14,8 +14,6 @@ import ( // ErrNotFound is returned by the admin db if an object couldn't be found. var ErrNotFound = errors.New("admindb: object not found") -// It's important to wrap all the model generated types into these since we don't want the admindb interfaces to depend on them. - // User holds all the information an authenticated user of the site has. type User struct { ID int64 @@ -30,6 +28,16 @@ func (aa ErrAlreadyAdded) Error() string { return fmt.Sprintf("admindb: the item (%s) is already on the list", aa.Ref.Ref()) } +// Invite is a combination of an invite id, who created it and an (optional) alias suggestion. +// The token itself is only visible from the db.Create function and stored hashed in the database +type Invite struct { + ID int64 + + CreatedBy User + + AliasSuggestion string +} + // ListEntry values are returned by Allow- and DenyListServices type ListEntry struct { ID int64 diff --git a/cmd/insert-user/main.go b/cmd/insert-user/main.go index 171aebe..4b2a2b3 100644 --- a/cmd/insert-user/main.go +++ b/cmd/insert-user/main.go @@ -9,12 +9,11 @@ import ( "os" "syscall" - "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" - _ "github.com/mattn/go-sqlite3" "golang.org/x/crypto/ssh/terminal" "github.com/ssb-ngi-pointer/go-ssb-room/admindb/sqlite" + "github.com/ssb-ngi-pointer/go-ssb-room/internal/repo" ) func main() { @@ -44,9 +43,12 @@ func main() { os.Exit(1) return } + ctx := context.Background() - err = db.AuthFallback.Create(ctx, os.Args[2], bytePassword) + uid, err := db.AuthFallback.Create(ctx, os.Args[2], bytePassword) check(err) + + fmt.Fprintln(os.Stderr, "created user with ID", uid) } func check(err error) { diff --git a/go.mod b/go.mod index 96b4410..7d70b1f 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/stretchr/testify v1.6.1 github.com/unrolled/secure v1.0.8 github.com/vcraescu/go-paginator/v2 v2.0.0 + github.com/volatiletech/sqlboiler-sqlite3 v0.0.0-20200618013359-a93887c09a14 // indirect github.com/volatiletech/sqlboiler/v4 v4.4.0 github.com/volatiletech/strmangle v0.0.1 go.cryptoscope.co/muxrpc/v2 v2.0.0-20210202162901-fe642d405dc6 diff --git a/go.sum b/go.sum index fc7736c..5e5ddaa 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,7 @@ github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVB github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/friendsofgo/errors v0.9.2 h1:X6NYxef4efCBdwI7BgS820zFaN7Cphrmb+Pljdzjtgk= github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.6.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -195,6 +196,7 @@ github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= @@ -202,6 +204,7 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -248,6 +251,7 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= @@ -269,6 +273,7 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -307,6 +312,7 @@ github.com/oxtoacart/bpool v0.0.0-20190524125616-8c0b41497736/go.mod h1:L3UMQOTh github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= @@ -371,18 +377,23 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.6.3 h1:pDDu1OyEDTKzpJwdq4TiuLyMsUgRa/BT5cn5O62NoHs= github.com/spf13/viper v1.6.3/go.mod h1:jUMtyi0/lB5yZH/FjyGAoH7IMNrIhlBf6pXZmbMDvzw= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= @@ -394,6 +405,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -418,6 +430,9 @@ github.com/volatiletech/null/v8 v8.1.0 h1:eAO3I31A5R04usY5SKMMfDcOCnEGyT/T4wRI0J github.com/volatiletech/null/v8 v8.1.0/go.mod h1:98DbwNoKEpRrYtGjWFctievIfm4n4MxG0A6EBUcoS5g= github.com/volatiletech/randomize v0.0.1 h1:eE5yajattWqTB2/eN8df4dw+8jwAzBtbdo5sbWC4nMk= github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY= +github.com/volatiletech/sqlboiler-sqlite3 v0.0.0-20200618013359-a93887c09a14 h1:2PCMsnM/GVptZVyB8s0vTIFCPjl6f5rhz5Ry5MNShMQ= +github.com/volatiletech/sqlboiler-sqlite3 v0.0.0-20200618013359-a93887c09a14/go.mod h1:fmZQG/eGdD2vdjWZjrVq4v2sTQ+Alz/I09chjYWWUVw= +github.com/volatiletech/sqlboiler/v4 v4.0.0/go.mod h1:U0Z5K4y+twWgHxh364G45QyzyNssSbBqNWtXGHVTlgM= github.com/volatiletech/sqlboiler/v4 v4.4.0 h1:aSlvHidRBuxHHQZNX3ZLGgzNVPVPzWqsC3lhcLbV/b0= github.com/volatiletech/sqlboiler/v4 v4.4.0/go.mod h1:h4RBAO6QbwMP3ezGmtfGljRms7S27cFIgF3rKgPKstE= github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk= @@ -587,6 +602,7 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw= gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=