@@ -0,0 +1,126 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
)
|
||||
|
||||
// GardenInviteModel stores garden invitations in PostgreSQL.
|
||||
// GardenInviteModel implements storage.GardenInviteModelInterface for PostgreSQL.
|
||||
type GardenInviteModel struct{ DB *sql.DB }
|
||||
|
||||
// Upsert creates or replaces a pending invitation for a garden and email.
|
||||
func (m GardenInviteModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) {
|
||||
invite.Token = rand.Text()
|
||||
hash := sha256.Sum256([]byte(invite.Token))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
err := m.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_invites (garden_id,email,role,token_hash,invited_by,expires_at)
|
||||
SELECT $1,$2,$3,$4,$5,$6
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
ON CONFLICT (garden_id,email) WHERE accepted_at IS NULL DO UPDATE SET
|
||||
role=EXCLUDED.role, token_hash=EXCLUDED.token_hash, invited_by=EXCLUDED.invited_by,
|
||||
expires_at=EXCLUDED.expires_at, created_at=now()
|
||||
RETURNING id, created_at`, invite.GardenID, invite.Email, invite.Role, hash[:], invite.InvitedBy, invite.ExpiresAt).Scan(&invite.ID, &invite.CreatedAt)
|
||||
return invite, err
|
||||
}
|
||||
|
||||
func scanInvite(row scanner) (storage.GardenInvite, error) {
|
||||
var invite storage.GardenInvite
|
||||
err := row.Scan(&invite.ID, &invite.GardenID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.AcceptedAt, &invite.CreatedAt)
|
||||
return invite, err
|
||||
}
|
||||
|
||||
// GetByToken returns an unexpired pending invitation by its plaintext token.
|
||||
func (m GardenInviteModel) GetByToken(tokenPlaintext string) (storage.GardenInvite, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
invite, err := scanInvite(m.DB.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 AND expires_at>now() AND accepted_at IS NULL`, hash[:]))
|
||||
if err != nil {
|
||||
return storage.GardenInvite{}, recordError(err)
|
||||
}
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
// GetAllForGarden lists pending invitations for a garden.
|
||||
func (m GardenInviteModel) GetAllForGarden(gardenID int) ([]storage.GardenInvite, error) {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE garden_id=$1 AND accepted_at IS NULL ORDER BY created_at DESC`, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []storage.GardenInvite{}
|
||||
for rows.Next() {
|
||||
invite, err := scanInvite(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, invite)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Delete revokes an invitation within its garden.
|
||||
func (m GardenInviteModel) Delete(gardenID, inviteID int) error {
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
result, err := m.DB.ExecContext(ctx, `DELETE FROM garden_invites WHERE garden_id=$1 AND id=$2 AND accepted_at IS NULL`, gardenID, inviteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return storage.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept consumes an invitation and creates or updates membership in one
|
||||
// transaction so a token cannot be accepted twice concurrently.
|
||||
func (m GardenInviteModel) Accept(tokenPlaintext string, user storage.User) (storage.GardenMember, error) {
|
||||
hash := sha256.Sum256([]byte(tokenPlaintext))
|
||||
ctx, cancel := contextWithTimeout()
|
||||
defer cancel()
|
||||
tx, err := m.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
invite, err := scanInvite(tx.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 FOR UPDATE`, hash[:]))
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, recordError(err)
|
||||
}
|
||||
if invite.AcceptedAt != nil || time.Now().After(invite.ExpiresAt) {
|
||||
return storage.GardenMember{}, storage.ErrRecordNotFound
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(invite.Email), strings.TrimSpace(user.Email)) {
|
||||
return storage.GardenMember{}, storage.ErrConflict
|
||||
}
|
||||
member := storage.GardenMember{GardenID: invite.GardenID, UserID: user.ID, Role: invite.Role}
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,$3) ON CONFLICT (garden_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING joined_at`, member.GardenID, member.UserID, member.Role).Scan(&member.JoinedAt)
|
||||
if err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE garden_invites SET accepted_at=now() WHERE id=$1`, invite.ID); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
if err = GardenMemberModel(m).loadPermissions(&member); err != nil {
|
||||
return storage.GardenMember{}, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
Reference in New Issue
Block a user