Files

106 lines
3.5 KiB
Go

package usersignup
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
// This file holds the one-time migration of the pre-actor signup tokens.
// The "actor tokens" migration freezes the signup_tokens table (and its user-group associations) into a JSON document stored in the "kv" table under the "signup_tokens_migrated" key.
// It's loaded here to create the per-token actors on first startup.
// signupTokensMigratedKey is the kv key under which the pre-actor signup tokens were frozen.
const signupTokensMigratedKey = "signup_tokens_migrated" // #nosec G101 -- database key name, not a credential
// migratedSignupToken is the JSON shape of a signup token frozen into the kv table by the migration.
// All timestamps are expressed as Unix seconds.
type migratedSignupToken struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"`
UserGroupIDs []string `json:"userGroupIds"`
CreatedAt int64 `json:"createdAt"`
}
// migrateSignupTokens creates an actor for every signup token frozen into the kv table by the migration.
// It requires the actor state store to be available, so it must run after the actor host is ready.
// It is idempotent: tokens that have already been migrated are left untouched, so a token that has been used since it was migrated is never reset.
func (s *Service) migrateSignupTokens(ctx context.Context) error {
migrated, err := loadMigratedSignupTokens(ctx, s.db)
if err != nil {
return err
}
if len(migrated) == 0 {
return nil
}
var count int
for _, m := range migrated {
// Skip tokens that have already expired, since there would be nothing left to store
expiresAt := time.Unix(m.ExpiresAt, 0)
if !expiresAt.After(time.Now()) {
continue
}
state := SignupTokenState{
ID: m.ID,
ExpiresAt: expiresAt,
UsageLimit: m.UsageLimit,
UsageCount: m.UsageCount,
UserGroupIDs: m.UserGroupIDs,
CreatedAt: time.Unix(m.CreatedAt, 0),
}
// The token's value is the actor's ID
// The "migrate" method only writes the state if the actor doesn't have one already
_, err = s.actorService.Invoke(ctx, SignupTokenActorType, m.Token, signupTokenMethodMigrate, state)
if err != nil {
return fmt.Errorf("error migrating signup token '%s': %w", m.ID, err)
}
count++
}
slog.InfoContext(ctx, "Migrated signup tokens to actors", slog.Int("count", count))
return nil
}
// loadMigratedSignupTokens reads the signup tokens frozen into the kv table by the migration
// It returns nil if there's nothing to migrate
func loadMigratedSignupTokens(ctx context.Context, db *gorm.DB) ([]migratedSignupToken, error) {
row := model.KV{
Key: signupTokensMigratedKey,
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := db.WithContext(ctx).First(&row).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// There are no migrated signup tokens in the database, nothing to do
return nil, nil
case err != nil:
return nil, fmt.Errorf("failed to load migrated signup tokens from the database: %w", err)
case row.Value == nil || len(*row.Value) == 0:
// Also no migrated signup tokens, nothing to do
return nil, nil
}
var migrated []migratedSignupToken
err = json.Unmarshal([]byte(*row.Value), &migrated)
if err != nil {
return nil, fmt.Errorf("error parsing migrated signup tokens: %w", err)
}
return migrated, nil
}