init service

This commit is contained in:
juancwu 2026-05-04 20:52:35 +00:00
commit 336bd34bd3
3 changed files with 141 additions and 0 deletions

132
service/service.go Normal file
View file

@ -0,0 +1,132 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"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")
}
if opts.IDGenerator == nil {
opts.IDGenerator = DefaultIDGenerator()
}
if opts.OperationTimeout < 0 {
return nil, errors.New("pase: OperationTimeout must be non-negative")
} else if opts.OperationTimeout == 0 {
opts.OperationTimeout = DefaultOperationTimeout
}
if opts.MagicLinkTTL < 0 {
return nil, errors.New("pase: MagicLinkTTL must be non-negative")
} else if opts.MagicLinkTTL == 0 {
opts.MagicLinkTTL = DefaultMagicLinkTTL
}
return &Service{
store: s,
idGenerator: opts.IDGenerator,
operationTimeout: opts.OperationTimeout,
magicLinkTTL: opts.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{Valid: false}
nullTime := store.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: "New registration",
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
}

7
store/errors.go Normal file
View file

@ -0,0 +1,7 @@
package store
import "errors"
var (
ErrUserEmailConflict = errors.New("pase.store: user email conflict")
)

View file

@ -6,6 +6,8 @@ import (
)
type Store interface {
// CreateUser inserts a new row into the users table.
// The method overrides the CreatedAt and UpdatedAt fields.
CreateUser(ctx context.Context, u *User) error
GetUserByID(ctx context.Context, id string) (*User, error)
GetUserByEmail(ctx context.Context, email string) (*User, error)