package store import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "io" "strings" "time" "unicode/utf8" "github.com/jackc/pgx/v5" "pargar/backend/internal/models" ) var ( ErrInviteNotFound = errors.New("invite not found") ErrInviteInvalid = errors.New("invite invalid") ErrInvitePaused = errors.New("invite paused") ErrInviteRevoked = errors.New("invite revoked") ErrInviteExhausted = errors.New("invite exhausted") ErrInviteExpired = errors.New("invite expired") ErrInviteMaxUsesTooLow = errors.New("maxUses below used_count") ErrInviteMaxUsesRange = errors.New("maxUses out of range") ) const inviteCols = `id, token_hash, token_enc, label, max_uses, used_count, status, expires_at, created_by, created_at` func HashInviteToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // EncryptInviteToken encrypts the raw invite token with AES-GCM keyed by secret. func EncryptInviteToken(secret, plaintext string) (string, error) { key := sha256.Sum256([]byte(secret)) block, err := aes.NewCipher(key[:]) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } out := gcm.Seal(nonce, nonce, []byte(plaintext), nil) return base64.RawURLEncoding.EncodeToString(out), nil } // DecryptInviteToken decrypts a token produced by EncryptInviteToken. func DecryptInviteToken(secret, ciphertext string) (string, error) { raw, err := base64.RawURLEncoding.DecodeString(ciphertext) if err != nil { return "", err } key := sha256.Sum256([]byte(secret)) block, err := aes.NewCipher(key[:]) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } if len(raw) < gcm.NonceSize() { return "", errors.New("ciphertext too short") } nonce, sealed := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] plain, err := gcm.Open(nil, nonce, sealed, nil) if err != nil { return "", err } return string(plain), nil } // NewInviteToken returns 32 random bytes encoded as base64url (no padding). func NewInviteToken() (string, error) { b := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func inviteRemaining(maxUses, usedCount int, status models.InviteStatus) int { // Admin still sees remaining capacity while paused; revoked/exhausted show 0. if status != models.InviteActive && status != models.InvitePaused { return 0 } rem := maxUses - usedCount if rem < 0 { return 0 } return rem } func scanInvite(row rowScanner) (*models.RegistrationInvite, error) { var inv models.RegistrationInvite var tokenHash, tokenEnc, status string err := row.Scan( &inv.ID, &tokenHash, &tokenEnc, &inv.Label, &inv.MaxUses, &inv.UsedCount, &status, &inv.ExpiresAt, &inv.CreatedBy, &inv.CreatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrInviteNotFound } if err != nil { return nil, err } inv.Status = models.InviteStatus(status) inv.Remaining = inviteRemaining(inv.MaxUses, inv.UsedCount, inv.Status) // Stash enc on Token temporarily for decrypt by callers that need it; clear before JSON if unused. inv.Token = tokenEnc return &inv, nil } func (s *Store) CreateRegistrationInvite( ctx context.Context, tokenHash, tokenEnc, label string, maxUses int, expiresAt *time.Time, createdBy int64, ) (*models.RegistrationInvite, error) { label = strings.TrimSpace(label) if utf8.RuneCountInString(label) > 120 { label = string([]rune(label)[:120]) } if maxUses < 1 { return nil, errors.New("maxUses must be >= 1") } row := s.pool.QueryRow(ctx, ` INSERT INTO registration_invites (token_hash, token_enc, label, max_uses, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING `+inviteCols, tokenHash, tokenEnc, label, maxUses, expiresAt, createdBy) return scanInvite(row) } func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) { if limit <= 0 { limit = 10 } if limit > 100 { limit = 100 } if offset < 0 { offset = 0 } query = strings.TrimSpace(query) var total int64 if err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM registration_invites WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil { return nil, 0, err } rows, err := s.pool.Query(ctx, ` SELECT `+inviteCols+` FROM registration_invites WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%') ORDER BY created_at DESC LIMIT $2 OFFSET $3`, query, limit, offset) if err != nil { return nil, 0, err } defer rows.Close() var out []models.RegistrationInvite for rows.Next() { inv, err := scanInvite(rows) if err != nil { return nil, 0, err } out = append(out, *inv) } return out, total, rows.Err() } func (s *Store) GetRegistrationInviteByID(ctx context.Context, id int64) (*models.RegistrationInvite, error) { row := s.pool.QueryRow(ctx, `SELECT `+inviteCols+` FROM registration_invites WHERE id=$1`, id) return scanInvite(row) } func (s *Store) GetRegistrationInviteByTokenHash(ctx context.Context, tokenHash string) (*models.RegistrationInvite, error) { row := s.pool.QueryRow(ctx, `SELECT `+inviteCols+` FROM registration_invites WHERE token_hash=$1`, tokenHash) return scanInvite(row) } func (s *Store) RevokeRegistrationInvite(ctx context.Context, id int64) (*models.RegistrationInvite, error) { row := s.pool.QueryRow(ctx, ` UPDATE registration_invites SET status = 'revoked' WHERE id = $1 AND status IN ('active', 'paused') RETURNING `+inviteCols, id) inv, err := scanInvite(row) if err != nil { if errors.Is(err, ErrInviteNotFound) { existing, getErr := s.GetRegistrationInviteByID(ctx, id) if getErr != nil { return nil, getErr } return existing, ErrInviteInvalid } return nil, err } return inv, nil } func (s *Store) PauseRegistrationInvite(ctx context.Context, id int64) (*models.RegistrationInvite, error) { row := s.pool.QueryRow(ctx, ` UPDATE registration_invites SET status = 'paused' WHERE id = $1 AND status = 'active' RETURNING `+inviteCols, id) inv, err := scanInvite(row) if err != nil { if errors.Is(err, ErrInviteNotFound) { existing, getErr := s.GetRegistrationInviteByID(ctx, id) if getErr != nil { return nil, getErr } return existing, ErrInviteInvalid } return nil, err } return inv, nil } func (s *Store) ResumeRegistrationInvite(ctx context.Context, id int64) (*models.RegistrationInvite, error) { row := s.pool.QueryRow(ctx, ` UPDATE registration_invites SET status = 'active' WHERE id = $1 AND status = 'paused' AND used_count < max_uses AND (expires_at IS NULL OR expires_at > now()) RETURNING `+inviteCols, id) inv, err := scanInvite(row) if err != nil { if errors.Is(err, ErrInviteNotFound) { existing, getErr := s.GetRegistrationInviteByID(ctx, id) if getErr != nil { return nil, getErr } if existing.Status != models.InvitePaused { return existing, ErrInviteInvalid } if existing.ExpiresAt != nil && !existing.ExpiresAt.After(time.Now()) { return existing, ErrInviteExpired } if existing.UsedCount >= existing.MaxUses { return existing, ErrInviteExhausted } return existing, ErrInviteInvalid } return nil, err } return inv, nil } // UpdateRegistrationInvite patches label, max uses, and/or expiry for an editable invite. // When setExpires is true, expiresAt is applied (nil clears expiry). func (s *Store) UpdateRegistrationInvite( ctx context.Context, id int64, label *string, maxUses *int, setExpires bool, expiresAt *time.Time, ) (*models.RegistrationInvite, error) { existing, err := s.GetRegistrationInviteByID(ctx, id) if err != nil { return nil, err } if existing.Status != models.InviteActive && existing.Status != models.InvitePaused { return existing, ErrInviteInvalid } newLabel := existing.Label if label != nil { newLabel = strings.TrimSpace(*label) if utf8.RuneCountInString(newLabel) > 120 { newLabel = string([]rune(newLabel)[:120]) } } newMax := existing.MaxUses if maxUses != nil { if *maxUses < 1 || *maxUses > 10_000 { return nil, ErrInviteMaxUsesRange } if *maxUses < existing.UsedCount { return nil, ErrInviteMaxUsesTooLow } newMax = *maxUses } newExpires := existing.ExpiresAt if setExpires { newExpires = expiresAt } row := s.pool.QueryRow(ctx, ` UPDATE registration_invites SET label = $2, max_uses = $3, expires_at = $4 WHERE id = $1 AND status IN ('active', 'paused') RETURNING `+inviteCols, id, newLabel, newMax, newExpires) return scanInvite(row) } // EvaluateInviteAvailability checks whether an invite can still accept registrations. func EvaluateInviteAvailability(inv *models.RegistrationInvite, now time.Time) error { if inv == nil { return ErrInviteNotFound } switch inv.Status { case models.InviteRevoked: return ErrInviteRevoked case models.InviteExhausted: return ErrInviteExhausted case models.InvitePaused: return ErrInvitePaused case models.InviteActive: // ok default: return ErrInviteInvalid } if inv.ExpiresAt != nil && !inv.ExpiresAt.After(now) { return ErrInviteExpired } if inv.UsedCount >= inv.MaxUses { return ErrInviteExhausted } return nil } // RegisterInviteStudent creates a student, optionally records the client IP, and consumes one invite seat. // Empty ip skips one-registration-per-IP enforcement (NAT / shared egress). func (s *Store) RegisterInviteStudent( ctx context.Context, inviteID int64, name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string, ) (*models.User, error) { ip = strings.TrimSpace(ip) tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) if blocked, err := isPhoneBlacklistedTx(ctx, tx, phone); err != nil { return nil, err } else if blocked { return nil, ErrPhoneBlacklisted } if ip != "" { var taken bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil { return nil, err } if taken { return nil, ErrRegistrationIPTaken } } tag, err := tx.Exec(ctx, ` UPDATE registration_invites SET used_count = used_count + 1, status = CASE WHEN used_count + 1 >= max_uses THEN 'exhausted' ELSE status END WHERE id = $1 AND status = 'active' AND used_count < max_uses AND (expires_at IS NULL OR expires_at > now())`, inviteID) if err != nil { return nil, err } if tag.RowsAffected() == 0 { var status string var used, maxUses int var expiresAt *time.Time err := tx.QueryRow(ctx, ` SELECT status, used_count, max_uses, expires_at FROM registration_invites WHERE id=$1`, inviteID).Scan(&status, &used, &maxUses, &expiresAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrInviteNotFound } if err != nil { return nil, err } switch models.InviteStatus(status) { case models.InviteRevoked: return nil, ErrInviteRevoked case models.InviteExhausted: return nil, ErrInviteExhausted case models.InvitePaused: return nil, ErrInvitePaused } if expiresAt != nil && !expiresAt.After(time.Now()) { return nil, ErrInviteExpired } if used >= maxUses { return nil, ErrInviteExhausted } return nil, ErrInviteInvalid } row := tx.QueryRow(ctx, ` INSERT INTO users (name, email, username, password_hash, role, phone, security_question, security_answer_hash) VALUES ($1, $2, $3, $4, 'student', $5, $6, $7) RETURNING `+userCols, name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash) u, err := scanUser(row) if err != nil { return nil, err } // Consume whitelist seat if this phone was listed (invite may use non-whitelist phones). if err := consumeWhitelistPhoneTx(ctx, tx, phone, u.ID, false); err != nil { return nil, err } if _, err := tx.Exec(ctx, ` INSERT INTO notification_prefs (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil { return nil, err } if ip != "" { if _, err := tx.Exec(ctx, ` INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil { if isUniqueViolation(err) { return nil, ErrRegistrationIPTaken } return nil, err } } if err := tx.Commit(ctx); err != nil { return nil, err } return u, nil }