Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
456 lines
12 KiB
Go
456 lines
12 KiB
Go
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, status 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)
|
|
status = strings.TrimSpace(status)
|
|
var total int64
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM registration_invites
|
|
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
|
|
AND ($2 = '' OR status = $2)`, query, status).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||'%')
|
|
AND ($2 = '' OR status = $2)
|
|
ORDER BY created_at DESC
|
|
LIMIT $3 OFFSET $4`, query, status, 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
|
|
}
|