Files
ahp d9a2abb44f feat: release v0.1.3 chat, profiles, and media
Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
2026-10-09 19:05:59 +03:30

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
}