Files

94 lines
3.2 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// SetSetupToken inserts a row, replacing any existing token for
// this user (single-outstanding invariant). Caller passes a hash —
// raw tokens are never persisted.
func (s *Store) SetSetupToken(ctx context.Context, t SetupToken) error {
_, err := s.db.ExecContext(ctx,
`INSERT OR REPLACE INTO user_setup_tokens
(user_id, token_hash, expires_at, created_at, created_by)
VALUES (?, ?, ?, ?, ?)`,
t.UserID, t.TokenHash,
t.ExpiresAt.UTC().Format(time.RFC3339Nano),
t.CreatedAt.UTC().Format(time.RFC3339Nano),
nullable(t.CreatedBy))
if err != nil {
return fmt.Errorf("store: set setup token: %w", err)
}
return nil
}
// LookupSetupToken resolves a token hash to its row. Returns
// ErrNotFound for missing tokens. Expiry is NOT checked here —
// callers must compare ExpiresAt themselves so they can record
// 'expired' as a distinct outcome (audit-able) from 'never existed'.
func (s *Store) LookupSetupToken(ctx context.Context, tokenHash string) (*SetupToken, error) {
row := s.db.QueryRowContext(ctx,
`SELECT user_id, token_hash, expires_at, created_at, created_by
FROM user_setup_tokens WHERE token_hash = ?`, tokenHash)
return scanSetupToken(row.Scan)
}
// GetSetupTokenByUserID returns the row for one user. Used by the
// edit page to know whether a 'Regenerate setup link' button should
// show as 'Generate' or 'Regenerate'. Returns ErrNotFound when no
// outstanding token exists.
func (s *Store) GetSetupTokenByUserID(ctx context.Context, userID string) (*SetupToken, error) {
row := s.db.QueryRowContext(ctx,
`SELECT user_id, token_hash, expires_at, created_at, created_by
FROM user_setup_tokens WHERE user_id = ?`, userID)
return scanSetupToken(row.Scan)
}
// DeleteSetupToken removes the row for a user (single-use cleanup
// after /setup completes successfully).
func (s *Store) DeleteSetupToken(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM user_setup_tokens WHERE user_id = ?`, userID)
if err != nil {
return fmt.Errorf("store: delete setup token: %w", err)
}
return nil
}
// CleanupExpiredSetupTokens removes rows whose expires_at has passed.
// Returns the number of rows deleted. Called from the maintenance
// ticker every minute.
func (s *Store) CleanupExpiredSetupTokens(ctx context.Context, now time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM user_setup_tokens WHERE expires_at < ?`,
now.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, fmt.Errorf("store: cleanup setup tokens: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
func scanSetupToken(scan func(...any) error) (*SetupToken, error) {
var t SetupToken
var createdBy sql.NullString
var expiresAt, createdAt string
if err := scan(&t.UserID, &t.TokenHash, &expiresAt, &createdAt, &createdBy); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("store: scan setup token: %w", err)
}
t.ExpiresAt, _ = time.Parse(time.RFC3339Nano, expiresAt)
t.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
if createdBy.Valid {
v := createdBy.String
t.CreatedBy = &v
}
return &t, nil
}