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:
OpenClaw Dev 2026-11-01 05:53:32 +01:00
parent d2658de099
commit cf5e0b1ca7
38 changed files with 665 additions and 25 deletions

163
internal/store/invites.go Normal file
View 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
}

View file

@ -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.

View file

@ -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