From 336bd34bd3742d083cc00137422d2f241af4b040 Mon Sep 17 00:00:00 2001 From: juancwu Date: Mon, 4 May 2026 20:52:35 +0000 Subject: [PATCH] init service --- service/service.go | 132 +++++++++++++++++++++++++++++++++++++++++++++ store/errors.go | 7 +++ store/store.go | 2 + 3 files changed, 141 insertions(+) create mode 100644 service/service.go create mode 100644 store/errors.go diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..95d2fee --- /dev/null +++ b/service/service.go @@ -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 +} diff --git a/store/errors.go b/store/errors.go new file mode 100644 index 0000000..4a6cec4 --- /dev/null +++ b/store/errors.go @@ -0,0 +1,7 @@ +package store + +import "errors" + +var ( + ErrUserEmailConflict = errors.New("pase.store: user email conflict") +) diff --git a/store/store.go b/store/store.go index af69f47..108adf7 100644 --- a/store/store.go +++ b/store/store.go @@ -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)