2021-10-08 12:39:31 +00:00
|
|
|
// SPDX-FileCopyrightText: 2021 The NGI Pointer Secure-Scuttlebutt Team of 2020/2021
|
|
|
|
//
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
2021-03-02 16:14:02 +00:00
|
|
|
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"
|
|
|
|
|
2022-11-07 09:18:13 +00:00
|
|
|
refs "github.com/ssbc/go-ssb-refs"
|
|
|
|
"github.com/ssbc/go-ssb-room/v2/roomdb"
|
|
|
|
"github.com/ssbc/go-ssb-room/v2/roomdb/sqlite/models"
|
2021-03-02 16:14:02 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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.
|
2022-11-07 09:18:13 +00:00
|
|
|
// aliasSuggestion is optional (empty string is fine) but can be used to disambiguate open invites. (See https://github.com/ssbc/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-29 08:23:03 +00:00
|
|
|
func (i Invites) Create(ctx context.Context, createdBy int64) (string, error) {
|
2021-03-02 16:14:02 +00:00
|
|
|
var newInvite = models.Invite{
|
2021-03-29 08:23:03 +00:00
|
|
|
CreatedBy: createdBy,
|
2021-03-02 16:14:02 +00:00
|
|
|
}
|
|
|
|
|
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 {
|
|
|
|
|
2021-05-14 13:11:29 +00:00
|
|
|
if createdBy == -1 {
|
|
|
|
config, err := models.FindConfig(ctx, tx, configRowID)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if config.PrivacyMode != roomdb.ModeOpen {
|
|
|
|
return fmt.Errorf("roomdb: privacy mode not set to open but %s", config.PrivacyMode.String())
|
|
|
|
}
|
|
|
|
|
|
|
|
m, err := models.Members(qm.Where("role = ?", roomdb.RoleAdmin)).One(ctx, tx)
|
|
|
|
if err != nil {
|
|
|
|
// we could insert something like a system user but should probably hit it from the members list then
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
return fmt.Errorf("roomdb: no admin user available to associate invite to")
|
|
|
|
}
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
newInvite.CreatedBy = m.ID
|
|
|
|
}
|
|
|
|
|
2021-03-02 16:14:02 +00:00
|
|
|
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)
|
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 {
|
2021-05-14 13:11:29 +00:00
|
|
|
return errors.New("roomdb: failed to generate an invite token in a reasonable amount of time")
|
2021-03-02 16:14:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
2021-04-01 06:05:07 +00:00
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
return roomdb.ErrNotFound
|
|
|
|
}
|
2021-03-02 16:14:02 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-03-29 08:23:03 +00:00
|
|
|
_, err = i.members.add(ctx, tx, newMember, roomdb.RoleMember)
|
2022-03-28 10:48:00 +00:00
|
|
|
var alreadyAdded roomdb.ErrAlreadyAdded
|
2021-03-02 16:14:02 +00:00
|
|
|
if err != nil {
|
2022-11-07 09:18:13 +00:00
|
|
|
if errors.As(err, &alreadyAdded) && alreadyAdded.Ref.Equal(newMember) {
|
2022-03-28 10:48:00 +00:00
|
|
|
// it is fine to use an invite twice
|
|
|
|
} else {
|
|
|
|
return err
|
|
|
|
}
|
2021-03-02 16:14:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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-18 16:49:52 +00:00
|
|
|
inv.CreatedBy.ID = entry.R.CreatedByMember.ID
|
|
|
|
inv.CreatedBy.Role = roomdb.Role(entry.R.CreatedByMember.Role)
|
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
|
|
|
|
}
|
|
|
|
|
2021-06-15 14:24:06 +00:00
|
|
|
// since invites are marked as invalid so that the code can't be generated twice,
|
2021-03-02 16:14:02 +00:00
|
|
|
// 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 {
|
2021-05-14 13:11:29 +00:00
|
|
|
return fmt.Errorf("roomdb: failed to delete used invites: %w", err)
|
2021-03-02 16:14:02 +00:00
|
|
|
}
|
|
|
|
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(
|
2021-03-26 19:08:13 +00:00
|
|
|
qm.Where("active = true AND hashed_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-18 16:49:52 +00:00
|
|
|
inv.CreatedBy.ID = entry.R.CreatedByMember.ID
|
|
|
|
inv.CreatedBy.Role = roomdb.Role(entry.R.CreatedByMember.Role)
|
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-18 16:49:52 +00:00
|
|
|
inv.CreatedBy.ID = entry.R.CreatedByMember.ID
|
|
|
|
inv.CreatedBy.Role = roomdb.Role(entry.R.CreatedByMember.Role)
|
2021-04-08 10:09:29 +00:00
|
|
|
inv.CreatedBy.PubKey = entry.R.CreatedByMember.PubKey.FeedRef
|
|
|
|
inv.CreatedBy.Aliases = i.members.getAliases(entry.R.CreatedByMember)
|
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-11-11 20:08:28 +00:00
|
|
|
qm.Load("CreatedByMember.Aliases"),
|
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-18 16:49:52 +00:00
|
|
|
inv.CreatedBy.ID = e.R.CreatedByMember.ID
|
2021-04-08 10:09:29 +00:00
|
|
|
inv.CreatedBy.PubKey = e.R.CreatedByMember.PubKey.FeedRef
|
|
|
|
inv.CreatedBy.Aliases = i.members.getAliases(e.R.CreatedByMember)
|
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
|
|
|
|
}
|
|
|
|
|
2021-06-12 21:43:04 +00:00
|
|
|
func (i Invites) Count(ctx context.Context, onlyActive bool) (uint, error) {
|
|
|
|
queryMod := qm.Where("1")
|
|
|
|
if onlyActive {
|
|
|
|
queryMod = qm.Where("active = true")
|
2021-06-10 15:02:16 +00:00
|
|
|
}
|
2021-06-12 21:43:04 +00:00
|
|
|
count, err := models.Invites(queryMod).Count(ctx, i.db)
|
2021-06-10 15:02:16 +00:00
|
|
|
if err != nil {
|
2021-03-29 15:21:26 +00:00
|
|
|
return 0, err
|
2021-03-29 14:33:15 +00:00
|
|
|
}
|
2021-03-29 15:21:26 +00:00
|
|
|
return uint(count), nil
|
2021-03-29 14:33:15 +00:00
|
|
|
}
|
|
|
|
|
2021-03-02 16:14:02 +00:00
|
|
|
// 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-05-14 13:11:29 +00:00
|
|
|
return "", fmt.Errorf("roomdb: invalid invite token length (only got %d bytes)", n)
|
2021-03-05 09:46:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// hash the binary of the passed token
|
|
|
|
h := sha256.New()
|
|
|
|
h.Write(tokenBytes)
|
|
|
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
|
|
}
|