127 lines
4.9 KiB
Go
127 lines
4.9 KiB
Go
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
|
|
}
|