package db import ( "crypto/rand" "database/sql" "encoding/hex" "errors" "time" "github.com/bluesky-social/indigo/atproto/syntax" ) var ( ErrInviteNotFound = errors.New("invite code is unknown, spent or expired") ErrInviteBanned = errors.New("invite redeemer is banned") ) const InviteTTL = 2 * 24 * time.Hour // minting sweeps the expired codes, so nothing else has to func (d *DB) MintInvite(createdBy string, now time.Time) (string, time.Time, error) { raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { return "", time.Time{}, err } code := hex.EncodeToString(raw) expires := now.Add(InviteTTL) tx, err := d.Begin() if err != nil { return "", time.Time{}, err } defer tx.Rollback() if _, err := tx.Exec(`delete from invite_codes where expires_at <= ?`, now.Unix()); err != nil { return "", time.Time{}, err } if _, err := tx.Exec( `insert into invite_codes (code, created_by, expires_at) values (?, ?, ?)`, code, createdBy, expires.Unix(), ); err != nil { return "", time.Time{}, err } return code, expires, tx.Commit() } // the delete is the spend, so of any number of racing redeems only one sees a row func (d *DB) RedeemInvite(code string, did syntax.DID, now time.Time) error { tx, err := d.Begin() if err != nil { return err } defer tx.Rollback() var banned bool if err := tx.QueryRow(`select exists(select 1 from bans where subject_did = ?)`, did).Scan(&banned); err != nil { return err } if banned { return ErrInviteBanned } var spent string err = tx.QueryRow(`delete from invite_codes where code = ? and expires_at > ? returning code`, code, now.Unix()).Scan(&spent) if errors.Is(err, sql.ErrNoRows) { return ErrInviteNotFound } if err != nil { return err } if err := AddToAllowlist(tx, did); err != nil { return err } return tx.Commit() }