Einladungscodes: registration_mode open|invite|closed, Admin-API /api/invites, App mit Code-Feld und Code-Verwaltung; v0.3.2
This commit is contained in:
parent
d2658de099
commit
cf5e0b1ca7
38 changed files with 665 additions and 25 deletions
163
internal/store/invites.go
Normal file
163
internal/store/invites.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInviteInvalid = errors.New("invalid invite code")
|
||||
ErrInviteUsed = errors.New("invite code already used")
|
||||
ErrInviteExpired = errors.New("invite code expired")
|
||||
)
|
||||
|
||||
// Invite is a registration code. ExpiresAt is zero when it never expires.
|
||||
type Invite struct {
|
||||
Code string `json:"code"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
Uses int `json:"uses"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UsedBy []string `json:"used_by"`
|
||||
}
|
||||
|
||||
// No 0/O/1/I/L to keep codes easy to read and type.
|
||||
const inviteAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
|
||||
func newInviteCode() (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString("BC-")
|
||||
for i := 0; i < 8; i++ {
|
||||
if i == 4 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(inviteAlphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(inviteAlphabet[n.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// NormalizeInviteCode upper-cases and trims user input ("bc-abcd-efgh " -> "BC-ABCD-EFGH").
|
||||
func NormalizeInviteCode(c string) string { return strings.ToUpper(strings.TrimSpace(c)) }
|
||||
|
||||
// CreateInvite stores a new random code. maxUses < 1 means 1; ttl <= 0 means no expiry.
|
||||
func (s *Store) CreateInvite(ctx context.Context, maxUses int, ttl time.Duration) (Invite, error) {
|
||||
if maxUses < 1 {
|
||||
maxUses = 1
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
inv := Invite{MaxUses: maxUses, CreatedAt: now, UsedBy: []string{}}
|
||||
var exp int64
|
||||
if ttl > 0 {
|
||||
t := now.Add(ttl)
|
||||
inv.ExpiresAt = &t
|
||||
exp = t.UnixMilli()
|
||||
}
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
return Invite{}, err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO invites(code,max_uses,uses,expires_at,created_at) VALUES(?,?,0,?,?)`, code, maxUses, exp, now.UnixMilli())
|
||||
if err == nil {
|
||||
inv.Code = code
|
||||
return inv, nil
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UNIQUE") {
|
||||
return Invite{}, err
|
||||
}
|
||||
}
|
||||
return Invite{}, errors.New("could not generate unique invite code")
|
||||
}
|
||||
|
||||
// ListInvites returns all codes, newest first, with the usernames that used them.
|
||||
func (s *Store) ListInvites(ctx context.Context) ([]Invite, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT code,max_uses,uses,expires_at,created_at FROM invites ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Invite
|
||||
idx := map[string]int{}
|
||||
for rows.Next() {
|
||||
var inv Invite
|
||||
var exp, c int64
|
||||
if err := rows.Scan(&inv.Code, &inv.MaxUses, &inv.Uses, &exp, &c); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
inv.CreatedAt = time.UnixMilli(c).UTC()
|
||||
if exp > 0 {
|
||||
t := time.UnixMilli(exp).UTC()
|
||||
inv.ExpiresAt = &t
|
||||
}
|
||||
inv.UsedBy = []string{}
|
||||
idx[inv.Code] = len(out)
|
||||
out = append(out, inv)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ur, err := s.db.QueryContext(ctx, `SELECT code,username FROM invite_uses ORDER BY used_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer ur.Close()
|
||||
for ur.Next() {
|
||||
var code, name string
|
||||
if err := ur.Scan(&code, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i, ok := idx[code]; ok {
|
||||
out[i].UsedBy = append(out[i].UsedBy, name)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []Invite{}
|
||||
}
|
||||
return out, ur.Err()
|
||||
}
|
||||
|
||||
// DeleteInvite revokes a code. Users who already registered with it stay.
|
||||
func (s *Store) DeleteInvite(ctx context.Context, code string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM invites WHERE code=?`, NormalizeInviteCode(code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func consumeInvite(ctx context.Context, tx *sql.Tx, code string, now time.Time) error {
|
||||
var maxUses, uses int
|
||||
var exp int64
|
||||
err := tx.QueryRowContext(ctx, `SELECT max_uses,uses,expires_at FROM invites WHERE code=?`, code).Scan(&maxUses, &uses, &exp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrInviteInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exp > 0 && now.UnixMilli() >= exp {
|
||||
return ErrInviteExpired
|
||||
}
|
||||
// Conditional update: never exceeds max_uses even under concurrency.
|
||||
res, err := tx.ExecContext(ctx, `UPDATE invites SET uses=uses+1 WHERE code=? AND uses<max_uses`, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInviteUsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -63,6 +63,14 @@ CREATE TABLE IF NOT EXISTS auth_tokens(
|
|||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
||||
CREATE TABLE IF NOT EXISTS invites(
|
||||
code TEXT PRIMARY KEY, max_uses INTEGER NOT NULL DEFAULT 1, uses INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS invite_uses(
|
||||
code TEXT NOT NULL REFERENCES invites(code) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL, used_at INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_uses_code ON invite_uses(code);
|
||||
`
|
||||
|
||||
// migrations add columns to databases created by older versions.
|
||||
|
|
|
|||
|
|
@ -26,12 +26,23 @@ func (u User) PasswordHash() string { return u.hash }
|
|||
// CreateUser inserts a user. The first user ever created adopts all sessions
|
||||
// that existed before accounts were introduced (user_id 0).
|
||||
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
|
||||
return s.CreateUserWithInvite(ctx, username, passwordHash, "")
|
||||
}
|
||||
|
||||
// CreateUserWithInvite creates a user and, if code is non-empty, consumes one
|
||||
// use of that invite code in the same transaction (all or nothing).
|
||||
func (s *Store) CreateUserWithInvite(ctx context.Context, username, passwordHash, code string) (User, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if code != "" {
|
||||
if err := consumeInvite(ctx, tx, code, now); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
|
||||
return User{}, err
|
||||
|
|
@ -44,6 +55,11 @@ func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (
|
|||
return User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
if code != "" {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO invite_uses(code,user_id,username,used_at) VALUES(?,?,?,?)`, code, id, username, now.UnixMilli()); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
|
||||
return User{}, err
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue