mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-20 20:06:33 +00:00
218 lines
7.4 KiB
Go
218 lines
7.4 KiB
Go
package usersignup
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/italypaleale/francis/actor"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
)
|
|
|
|
// Signup tokens are stored entirely in the actor state store.
|
|
// Each token is its own actor, whose actor ID is the token value itself.
|
|
// The state is persisted with a TTL equal to the token's lifetime, so it's purged automatically when the token expires (there's no separate cleanup job and no expiration alarm).
|
|
// Listing tokens uses ListStates, which only returns states that haven't expired yet.
|
|
|
|
// SignupTokenActorType is the actor type for the signup token actor
|
|
const SignupTokenActorType = "SignupToken"
|
|
|
|
// Methods exposed by the signup token actor
|
|
// Because we cannot invoke an actor while a DB transaction is open (that would deadlock on SQLite), consuming a token is done by invoking the actor first (which atomically validates it and increments its usage count), and only afterwards performing the remaining work.
|
|
// On failure, the caller compensates by releasing the token via the "release" method as best-effort.
|
|
const (
|
|
// SignupTokenMethodCreate stores a new signup token, replacing any existing state
|
|
SignupTokenMethodCreate = "create"
|
|
// SignupTokenMethodDelete removes a signup token
|
|
SignupTokenMethodDelete = "delete"
|
|
|
|
signupTokenMethodMigrate = "migrate"
|
|
signupTokenMethodConsume = "consume"
|
|
signupTokenMethodRelease = "release"
|
|
)
|
|
|
|
// signupTokenConsumeStatus is the outcome of a "consume" invocation.
|
|
type signupTokenConsumeStatus string
|
|
|
|
const (
|
|
// signupTokenConsumeOK indicates the token was valid and one use has been consumed
|
|
signupTokenConsumeOK signupTokenConsumeStatus = "ok"
|
|
// signupTokenConsumeNotFound indicates the token doesn't exist (or has expired)
|
|
signupTokenConsumeNotFound signupTokenConsumeStatus = "not_found"
|
|
// signupTokenConsumeLimitReached indicates the token has no uses left
|
|
signupTokenConsumeLimitReached signupTokenConsumeStatus = "limit_reached"
|
|
)
|
|
|
|
// SignupTokenState is the persisted state of a signup token actor.
|
|
// The token value itself is the actor's ID, so it isn't repeated here.
|
|
type SignupTokenState struct {
|
|
ID string
|
|
UsageLimit int
|
|
UsageCount int
|
|
UserGroupIDs []string
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// signupTokenConsumeResponse is the response of the "consume" method
|
|
type signupTokenConsumeResponse struct {
|
|
Status signupTokenConsumeStatus
|
|
// UserGroupIDs is set only when Status is "ok", and contains the groups the new user should join
|
|
UserGroupIDs []string
|
|
}
|
|
|
|
// signupTokenActor is the actor that manages a single signup token
|
|
type signupTokenActor struct {
|
|
log *slog.Logger
|
|
client actor.Client[SignupTokenState]
|
|
}
|
|
|
|
// NewSignupTokenActor allocates a new signup token actor
|
|
// It satisfies actor.Factory
|
|
func NewSignupTokenActor(actorID string, service *actor.Service) actor.Actor {
|
|
return &signupTokenActor{
|
|
log: slog.With(
|
|
slog.String("scope", "actor"),
|
|
slog.String("actorType", SignupTokenActorType),
|
|
),
|
|
client: actor.NewActorClient[SignupTokenState](SignupTokenActorType, actorID, service),
|
|
}
|
|
}
|
|
|
|
// Invoke implements actor.ActorInvoke
|
|
func (a *signupTokenActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
|
|
switch method {
|
|
case SignupTokenMethodCreate:
|
|
return nil, a.create(parentCtx, data, false)
|
|
case signupTokenMethodMigrate:
|
|
return nil, a.create(parentCtx, data, true)
|
|
case signupTokenMethodConsume:
|
|
return a.consume(parentCtx)
|
|
case signupTokenMethodRelease:
|
|
return nil, a.release(parentCtx)
|
|
case SignupTokenMethodDelete:
|
|
return nil, a.delete(parentCtx)
|
|
default:
|
|
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
|
}
|
|
}
|
|
|
|
// create stores the token's state.
|
|
// When onlyIfMissing is true the write is skipped if the actor already has state: this is used by the one-time migration of the pre-actor tokens, so a token that has already been migrated is never reset.
|
|
func (a *signupTokenActor) create(parentCtx context.Context, data actor.Envelope, onlyIfMissing bool) error {
|
|
if data == nil {
|
|
return fmt.Errorf("request body is empty for method '%s'", SignupTokenMethodCreate)
|
|
}
|
|
|
|
var state SignupTokenState
|
|
err := data.Decode(&state)
|
|
if err != nil {
|
|
return fmt.Errorf("request body is not valid for method '%s': %w", SignupTokenMethodCreate, err)
|
|
}
|
|
|
|
if onlyIfMissing {
|
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
|
defer cancel()
|
|
current, err := a.client.GetState(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("error retrieving actor state: %w", err)
|
|
}
|
|
|
|
// An empty ID means there's no state yet
|
|
if current.ID != "" {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return a.setState(parentCtx, state)
|
|
}
|
|
|
|
// consume atomically validates the token and, if it's still usable, records one more use.
|
|
func (a *signupTokenActor) consume(parentCtx context.Context) (signupTokenConsumeResponse, error) {
|
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
|
defer cancel()
|
|
state, err := a.client.GetState(ctx)
|
|
if err != nil {
|
|
return signupTokenConsumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err)
|
|
}
|
|
|
|
// An empty ID means there's no state: the token doesn't exist (or its state already expired and was purged)
|
|
if state.ID == "" || state.ExpiresAt.Before(time.Now()) {
|
|
return signupTokenConsumeResponse{
|
|
Status: signupTokenConsumeNotFound,
|
|
}, nil
|
|
}
|
|
|
|
if state.UsageCount >= state.UsageLimit {
|
|
return signupTokenConsumeResponse{
|
|
Status: signupTokenConsumeLimitReached,
|
|
}, nil
|
|
}
|
|
|
|
// Consume one use of the token
|
|
state.UsageCount++
|
|
err = a.setState(parentCtx, state)
|
|
if err != nil {
|
|
return signupTokenConsumeResponse{}, err
|
|
}
|
|
|
|
return signupTokenConsumeResponse{
|
|
Status: signupTokenConsumeOK,
|
|
UserGroupIDs: state.UserGroupIDs,
|
|
}, nil
|
|
}
|
|
|
|
// release reverts the usage count increment performed while consuming the token, to compensate when the signup could not be completed.
|
|
func (a *signupTokenActor) release(parentCtx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
|
defer cancel()
|
|
state, err := a.client.GetState(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("error retrieving actor state: %w", err)
|
|
}
|
|
|
|
// The token is gone (for example, it expired and was purged) or was never consumed: nothing to compensate
|
|
if state.ID == "" || state.UsageCount <= 0 {
|
|
return nil
|
|
}
|
|
|
|
state.UsageCount--
|
|
return a.setState(parentCtx, state)
|
|
}
|
|
|
|
// delete removes the token.
|
|
func (a *signupTokenActor) delete(parentCtx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
|
defer cancel()
|
|
err := a.client.DeleteState(ctx)
|
|
if err != nil && !errors.Is(err, actor.ErrStateNotFound) {
|
|
// Deleting a token that doesn't exist (for example, one that expired in the meanwhile) already reaches the desired end state
|
|
return fmt.Errorf("error deleting actor state: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setState saves the state with a TTL matching the token's remaining lifetime, so it's purged automatically once the token expires.
|
|
// Saving is skipped if the token has already expired, since there would be nothing left to store.
|
|
func (a *signupTokenActor) setState(parentCtx context.Context, state SignupTokenState) error {
|
|
ttl := time.Until(state.ExpiresAt)
|
|
if ttl <= 0 {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
|
defer cancel()
|
|
err := a.client.SetState(ctx, state, &actor.SetStateOpts{
|
|
TTL: ttl,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("error saving actor state: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|