pase/service/service.go
2026-05-06 00:16:39 +00:00

136 lines
3.4 KiB
Go

package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"time"
"git.juancwu.dev/juancwu/pase/store"
)
type Service struct {
store store.Store
idGenerator IDGenerator
operationTimeout time.Duration
magicLinkTTL time.Duration
}
type Options struct {
IDGenerator IDGenerator
OperationTimeout time.Duration
MagicLinkTTL time.Duration
}
const (
DefaultOperationTimeout = time.Second * 10
DefaultMagicLinkTTL = time.Minute * 5
)
func New(s store.Store, opts Options) (*Service, error) {
if s == nil {
return nil, errors.New("pase: store is required")
}
idGenerator := DefaultIDGenerator()
if opts.IDGenerator != nil {
idGenerator = opts.IDGenerator
}
operationTimeout := DefaultOperationTimeout
if opts.OperationTimeout < 0 {
return nil, errors.New("pase: OperationTimeout must be non-negative")
} else if opts.OperationTimeout != 0 {
operationTimeout = opts.OperationTimeout
}
magicLinkTTL := DefaultMagicLinkTTL
if opts.MagicLinkTTL < 0 {
return nil, errors.New("pase: MagicLinkTTL must be non-negative")
} else if opts.MagicLinkTTL != 0 {
magicLinkTTL = opts.MagicLinkTTL
}
return &Service{
store: s,
idGenerator: idGenerator,
operationTimeout: operationTimeout,
magicLinkTTL: magicLinkTTL,
}, nil
}
// RegisterUser registers a new user with the given email and defaults.
// Check for errors.Is(err, store.ErrUserEmailConflict) to handle duplicate emails.
func (s *Service) RegisterUser(ctx context.Context, email string) (*store.User, error) {
const op = "pase.Service.RegisterUser"
timeoutCtx, cancel := context.WithTimeout(ctx, s.operationTimeout)
defer cancel()
nullString := store.NullString{NullString: sql.NullString{Valid: false}}
nullTime := store.NullTime{NullTime: sql.NullTime{Valid: false}}
user := store.User{
ID: s.idGenerator.NewID(),
Email: email,
EmailVerifiedAt: nullTime,
Username: nullString,
UsernameNormalized: nullString,
DisplayName: nullString,
ProfileImageURL: nullString,
Status: store.UserStatusActive,
StatusReason: nullString,
StatusChangedAt: nullTime,
StatusExpiresAt: nullTime,
FailedLoginCount: 0,
LastFailedLoginAt: nullTime,
}
err := s.store.CreateUser(timeoutCtx, &user)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
return &user, nil
}
// GenerateMagicLinkToken generates an opaque token encoded in base64 (url-safe) with expiration set to
// the provided MagicLinkTTL or DefaultMagicLinkTTL.
func (s *Service) GenerateMagicLinkToken(ctx context.Context, userID string) (string, error) {
const op = "pase.Service.GenerateMagicLinkToken"
timeoutCtx, cancel := context.WithTimeout(ctx, s.operationTimeout)
defer cancel()
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("%s: %w", op, err)
}
sum := sha256.Sum256(b)
hashedToken := base64.RawURLEncoding.EncodeToString(sum[:])
token := store.Token{
ID: s.idGenerator.NewID(),
UserID: userID,
Purpose: store.TokenPurposeMagicLink,
HashedValue: hashedToken,
Payload: nil,
ExpiresAt: time.Now().Add(s.magicLinkTTL),
}
err := s.store.CreateToken(timeoutCtx, &token)
if err != nil {
return "", fmt.Errorf("%s: %w", op, err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}