From bbcab73cb53a36e2258917fa7add9cf9eeae0a3a Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 2 Mar 2021 17:14:02 +0100 Subject: [PATCH] add admindb.InviteService interface methods: create, consume, list and revoke. SQLite implementation and some light testing. Related changes: * have authfallback.Create return the user id At some point we will need to not assume that authfallback is our users table but that will not become relevant before we start adding moderation roles. * Update package documentation of admindb and admindb/sqlite * remove leftover generated.db now using the roomdb file created by TestSimple Review comments by @cblgh * better documentation of hashed token storage * space between %d and `bytes` * make interface assertion comments less scary --- admindb/interface.go | 31 +- admindb/mockdb/auth_fallback.go | 33 +- admindb/mockdb/invite.go | 360 ++++++++ admindb/sqlite/aliases.go | 2 +- admindb/sqlite/auth_fallback.go | 10 +- admindb/sqlite/auth_withssb.go | 2 +- admindb/sqlite/generate_models.sh | 18 +- admindb/sqlite/generated.db | Bin 32768 -> 0 bytes admindb/sqlite/invites.go | 207 +++++ admindb/sqlite/invites_test.go | 110 +++ admindb/sqlite/migrations/4-invites.sql | 22 + admindb/sqlite/models/auth_fallback.go | 178 +++- admindb/sqlite/models/boil_table_names.go | 2 + admindb/sqlite/models/invites.go | 972 ++++++++++++++++++++++ admindb/sqlite/new.go | 46 +- admindb/sqlite/new_test.go | 3 +- admindb/sqlite/notices.go | 4 +- admindb/sqlite/roomcfg.go | 12 +- admindb/sqlite/roomcfg_test.go | 7 +- admindb/types.go | 12 +- cmd/insert-user/main.go | 8 +- go.mod | 1 + go.sum | 16 + 23 files changed, 2018 insertions(+), 38 deletions(-) create mode 100644 admindb/mockdb/invite.go delete mode 100644 admindb/sqlite/generated.db create mode 100644 admindb/sqlite/invites.go create mode 100644 admindb/sqlite/invites_test.go create mode 100644 admindb/sqlite/migrations/4-invites.sql create mode 100644 admindb/sqlite/models/invites.go 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 349dfddcaa09611adfa4dd14b06d865e4f76c9ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI&zi-+=6u|K_Avi+f$Hu_&)P+VWm6A-ET3T6E6hZ@qA(M43jzDqjCN@gCh5Q5g zH@fvN=>OHRN9weo28qu1wPbwfyF>RrPd7Zlr+#;ug>s%;U+PQ_R9!iadMl!oDwuo6 z+)vk{UwrDAr?b>v*DR>lAHG#;Kb2c~P}T3XAGQ0+L-nrOufABfg!c#_fB*srAb8NQu z4sX+|$drq|o@L2n-_Lj9ALNalc4?F8`}uUYy}ilwy?lCUe(Jc5R?E5ffBVd`iGQx6 zDA1oT%%=W!3aeJjL;ovgXysWpou)U*_1K^2bRt2N1QI8i#Iq%ZS2Z_HKR zJ=`iaPFibymv`>(uf$k9ENs4XZ)NG0IZcnXePwa1>Q 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=