2021-03-02 16:14:02 +00:00
package sqlite
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
2021-03-16 16:13:01 +00:00
"time"
2021-03-02 16:14:02 +00:00
"github.com/friendsofgo/errors"
"github.com/mattn/go-sqlite3"
"github.com/volatiletech/sqlboiler/v4/boil"
"github.com/volatiletech/sqlboiler/v4/queries/qm"
2021-03-10 15:44:46 +00:00
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/sqlite/models"
2021-03-02 16:14:02 +00:00
refs "go.mindeco.de/ssb-refs"
)
// compiler assertion to ensure the struct fullfills the interface
2021-03-18 16:49:52 +00:00
var _ roomdb . InvitesService = ( * Invites ) ( nil )
2021-03-02 16:14:02 +00:00
2021-03-10 15:44:46 +00:00
// Invites implements the roomdb.InviteService.
2021-03-02 16:14:02 +00:00
// Tokens are stored as sha256 hashes on disk to protect against attackers gaining database read-access.
type Invites struct {
db * sql . DB
2021-03-18 16:49:52 +00:00
members Members
2021-03-02 16:14:02 +00:00
}
// 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)
2021-03-17 09:46:05 +00:00
// The returned token is base64 URL encoded and has inviteTokenLength when decoded.
2021-03-02 16:14:02 +00:00
func ( i Invites ) Create ( ctx context . Context , createdBy int64 , aliasSuggestion string ) ( string , error ) {
var newInvite = models . Invite {
CreatedBy : createdBy ,
AliasSuggestion : aliasSuggestion ,
}
2021-03-17 09:46:05 +00:00
tokenBytes := make ( [ ] byte , inviteTokenLength )
2021-03-02 16:14:02 +00:00
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 )
2021-03-16 16:13:01 +00:00
// see comment on migrations/6-invite-createdAt.sql
newInvite . CreatedAt = time . Now ( )
2021-03-02 16:14:02 +00:00
// hash the binary of the token for storage
h := sha256 . New ( )
h . Write ( tokenBytes )
2021-03-18 16:49:52 +00:00
newInvite . HashedToken = fmt . Sprintf ( "%x" , h . Sum ( nil ) )
2021-03-02 16:14:02 +00:00
// 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.
2021-03-17 09:46:05 +00:00
// Tokens need to be base64 URL encoded and when decoded be of inviteTokenLength.
2021-03-10 15:44:46 +00:00
func ( i Invites ) Consume ( ctx context . Context , token string , newMember refs . FeedRef ) ( roomdb . Invite , error ) {
var inv roomdb . Invite
2021-03-02 16:14:02 +00:00
2021-03-05 09:46:59 +00:00
hashedToken , err := getHashedToken ( token )
2021-03-02 16:14:02 +00:00
if err != nil {
return inv , err
}
err = transact ( i . db , func ( tx * sql . Tx ) error {
entry , err := models . Invites (
2021-03-18 16:49:52 +00:00
qm . Where ( "active = true AND hashed_token = ?" , hashedToken ) ,
qm . Load ( "CreatedByMember" ) ,
2021-03-02 16:14:02 +00:00
) . One ( ctx , tx )
if err != nil {
return err
}
2021-03-18 16:49:52 +00:00
memberNick := time . Now ( ) . Format ( "new-member 2006-01-02" )
memberNick += "(invited by:" + entry . R . CreatedByMember . Nick + ")"
if entry . AliasSuggestion != "" {
memberNick = entry . AliasSuggestion
}
_ , err = i . members . add ( ctx , tx , memberNick , newMember , roomdb . RoleMember )
2021-03-02 16:14:02 +00:00
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
2021-03-16 16:13:01 +00:00
inv . CreatedAt = entry . CreatedAt
2021-03-02 16:14:02 +00:00
inv . AliasSuggestion = entry . AliasSuggestion
2021-03-18 16:49:52 +00:00
inv . CreatedBy . ID = entry . R . CreatedByMember . ID
inv . CreatedBy . Role = roomdb . Role ( entry . R . CreatedByMember . Role )
inv . CreatedBy . Nickname = entry . R . CreatedByMember . Nick
2021-03-02 16:14:02 +00:00
return nil
} )
if err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
2021-03-10 15:44:46 +00:00
return inv , roomdb . ErrNotFound
2021-03-02 16:14:02 +00:00
}
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
}
2021-03-10 15:44:46 +00:00
func ( i Invites ) GetByToken ( ctx context . Context , token string ) ( roomdb . Invite , error ) {
var inv roomdb . Invite
2021-03-05 09:46:59 +00:00
ht , err := getHashedToken ( token )
if err != nil {
return inv , err
}
entry , err := models . Invites (
qm . Where ( "active = true AND token = ?" , ht ) ,
2021-03-18 16:49:52 +00:00
qm . Load ( "CreatedByMember" ) ,
2021-03-05 09:46:59 +00:00
) . One ( ctx , i . db )
if err != nil {
2021-03-05 10:15:36 +00:00
if errors . Is ( err , sql . ErrNoRows ) {
2021-03-10 15:44:46 +00:00
return inv , roomdb . ErrNotFound
2021-03-05 10:15:36 +00:00
}
2021-03-05 09:46:59 +00:00
return inv , err
}
inv . ID = entry . ID
2021-03-16 16:13:01 +00:00
inv . CreatedAt = entry . CreatedAt
2021-03-05 09:46:59 +00:00
inv . AliasSuggestion = entry . AliasSuggestion
2021-03-18 16:49:52 +00:00
inv . CreatedBy . ID = entry . R . CreatedByMember . ID
inv . CreatedBy . Role = roomdb . Role ( entry . R . CreatedByMember . Role )
inv . CreatedBy . Nickname = entry . R . CreatedByMember . Nick
2021-03-05 09:46:59 +00:00
return inv , nil
}
2021-03-10 15:44:46 +00:00
func ( i Invites ) GetByID ( ctx context . Context , id int64 ) ( roomdb . Invite , error ) {
var inv roomdb . Invite
2021-03-05 09:46:59 +00:00
entry , err := models . Invites (
qm . Where ( "active = true AND id = ?" , id ) ,
2021-03-18 16:49:52 +00:00
qm . Load ( "CreatedByMember" ) ,
2021-03-05 09:46:59 +00:00
) . One ( ctx , i . db )
if err != nil {
2021-03-05 10:15:36 +00:00
if errors . Is ( err , sql . ErrNoRows ) {
2021-03-10 15:44:46 +00:00
return inv , roomdb . ErrNotFound
2021-03-05 10:15:36 +00:00
}
2021-03-05 09:46:59 +00:00
return inv , err
}
inv . ID = entry . ID
2021-03-16 16:13:01 +00:00
inv . CreatedAt = entry . CreatedAt
2021-03-05 09:46:59 +00:00
inv . AliasSuggestion = entry . AliasSuggestion
2021-03-18 16:49:52 +00:00
inv . CreatedBy . ID = entry . R . CreatedByMember . ID
inv . CreatedBy . Role = roomdb . Role ( entry . R . CreatedByMember . Role )
inv . CreatedBy . Nickname = entry . R . CreatedByMember . Nick
2021-03-05 09:46:59 +00:00
return inv , nil
}
2021-03-02 16:14:02 +00:00
// List returns a list of all the valid invites
2021-03-10 15:44:46 +00:00
func ( i Invites ) List ( ctx context . Context ) ( [ ] roomdb . Invite , error ) {
var invs [ ] roomdb . Invite
2021-03-02 16:14:02 +00:00
err := transact ( i . db , func ( tx * sql . Tx ) error {
entries , err := models . Invites (
qm . Where ( "active = true" ) ,
2021-03-18 16:49:52 +00:00
qm . Load ( "CreatedByMember" ) ,
2021-03-02 16:14:02 +00:00
) . All ( ctx , tx )
if err != nil {
return err
}
2021-03-10 15:44:46 +00:00
invs = make ( [ ] roomdb . Invite , len ( entries ) )
2021-03-11 07:52:53 +00:00
for idx , e := range entries {
2021-03-10 15:44:46 +00:00
var inv roomdb . Invite
2021-03-02 16:14:02 +00:00
inv . ID = e . ID
2021-03-16 16:13:01 +00:00
inv . CreatedAt = e . CreatedAt
2021-03-02 16:14:02 +00:00
inv . AliasSuggestion = e . AliasSuggestion
2021-03-18 16:49:52 +00:00
inv . CreatedBy . ID = e . R . CreatedByMember . ID
inv . CreatedBy . Nickname = e . R . CreatedByMember . Nick
2021-03-02 16:14:02 +00:00
2021-03-11 07:52:53 +00:00
invs [ idx ] = inv
2021-03-02 16:14:02 +00:00
}
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 {
2021-03-05 10:15:36 +00:00
if errors . Is ( err , sql . ErrNoRows ) {
2021-03-10 15:44:46 +00:00
return roomdb . ErrNotFound
2021-03-05 10:15:36 +00:00
}
2021-03-02 16:14:02 +00:00
return err
}
entry . Active = false
_ , err = entry . Update ( ctx , tx , boil . Whitelist ( "active" ) )
if err != nil {
return err
}
return nil
} )
}
2021-03-05 09:46:59 +00:00
2021-03-17 09:46:05 +00:00
const inviteTokenLength = 50
2021-03-05 09:46:59 +00:00
func getHashedToken ( b64tok string ) ( string , error ) {
tokenBytes , err := base64 . URLEncoding . DecodeString ( b64tok )
if err != nil {
return "" , err
}
2021-03-17 09:46:05 +00:00
if n := len ( tokenBytes ) ; n != inviteTokenLength {
2021-03-05 09:46:59 +00:00
return "" , 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 )
return fmt . Sprintf ( "%x" , h . Sum ( nil ) ) , nil
}