diff --git a/README.md b/README.md index 3712d1a..e4f8a28 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,31 @@ # 🚀 Serving gopl.dev -# templ live watch & reload -`go tool templ generate --watch --proxy="http://localhost:8080" --cmd="go run ./cmd/server/main.go"` +### Contributing -# tailwind watch -`tailwindcss -i ./frontend/assets/input.css -o ./frontend/assets/output.css --watch` +We don't have a formal set of rules for contributions yet; everyone is welcome! We appreciate everything from critiques and suggestions to bug fixes and new features. -# linting -https://golangci-lint.run/docs/welcome/install/local/ +### Setup Your Own Instance -`golangci-lint run` +See [SETUP.md](SETUP.md) for detailed instructions on how to set up your own instance. +### Internal Tools -# openapi & swagger -https://github.com/swaggo/swag -`swag fmt --dir server/handler` -`swag init --parseDependency --parseDepth 1 --dir server/handler -g handler.go -o server/docs` +* **Reset Dev Environment** + ```bash + go run ./cmd/cli/main.go rde + ``` + Resets the development environment by recreating the database, applying migrations, and creating a default user. This is useful during active development if you need a clean state. -# internal devtools -`go run ./cmd/cli/main.go rde` -Reset dev environment (recreate DB, apply migrations & create default user). Useful during active development when you messed with the DB or need a clean state. -`go run ./cmd/cli/main.go sd` -Will seed data to the database. By default, it seeds all available data. You can specify an entity and a count, for example: -`go run ./cmd/cli/main.go sd users 1000`. -Run `go run ./cmd/cli/main.go ? sd` to see available options and a detailed description. +* **Database Seeding** + ```bash + go run ./cmd/cli/main.go sd + ``` + Seeds data into the database. By default, it seeds all available data. You can specify an entity and a count: + `go run ./cmd/cli/main.go sd users 1000` + Run `go run ./cmd/cli/main.go ? sd` to see all available options and detailed descriptions. +--- + +License [MIT](LICENSE) \ No newline at end of file diff --git a/SETUP.md b/SETUP.md index 0b00fe3..a8090c8 100644 --- a/SETUP.md +++ b/SETUP.md @@ -63,7 +63,7 @@ By default, it seeds all available entities. You can specify a specific entity a * **Help:** `go run ./cmd/cli/main.go ? sd` for detailed options. ## Environment Reset -If you need a clean slate, run: +If you need a clean state, run: ```bash go run ./cmd/cli/main.go rde ``` diff --git a/WIP.md b/WIP.md index 2b0526d..7c3bfba 100644 --- a/WIP.md +++ b/WIP.md @@ -2,21 +2,17 @@ for simplicity, I keep todo list and progress here for now -**Before release**: -- [ ] Review "TODO!"s -- [x] Homepage -- [ ] Licence -- [x] RELEASE ---- - Tweaks & fixes: -- [ ] Add description to upload book cover input +- [X] Add description and limits to upload book cover input - [ ] When book cover upload ends with error, another cannot be added TODO: - [ ] Resend verification link (Sometimes email is lost in somewhere between the woods (Not mailman to blame)) -- [ ] Create new topic when creating/editing entity +- [ ] Topics + -[ ] Manage topics (create, edit, delete) + -[ ] Create new topic when creating/editing entity +- [ ] Entity highlight (Show something exciting: New book just released, latest book, random book.) - [ ] Pages - [ ] Add meta to page (Who created, last edit by who and list of activities on page (api call)) - [ ] Content chips @@ -29,6 +25,7 @@ TODO: - [ ] Preview for markdown - [ ] Convert all TODO's into tasks/issues - [ ] Create a CLI command to set up a new dev environment +- [ ] Showcase - [ ] Jobs - [ ] Events - [ ] Software diff --git a/app/config.go b/app/config.go index 65de321..bceb3ce 100644 --- a/app/config.go +++ b/app/config.go @@ -102,22 +102,22 @@ type ConfigT struct { Admins []string `yaml:"admins"` } -// IsDevEnv ... +// IsDevEnv returns true if the application environment is set to dev. func (c *ConfigT) IsDevEnv() bool { return c.App.Env == DevEnv } -// IsTestEnv ... +// IsTestEnv returns true if the application environment is set to test. func (c *ConfigT) IsTestEnv() bool { return c.App.Env == TestEnv } -// IsProductionEnv ... +// IsProductionEnv returns true if the application environment is set to production. func (c *ConfigT) IsProductionEnv() bool { return c.App.Env == ProductionEnv } -// TracingDisabled ... +// TracingDisabled returns true if the tracing configuration is not enabled. func (c *ConfigT) TracingDisabled() bool { return !c.Tracing.Enabled } diff --git a/app/database.go b/app/database.go index 770456d..530cc35 100644 --- a/app/database.go +++ b/app/database.go @@ -28,7 +28,7 @@ var ( ErrMultipleSameVersion = errors.New("multiple migrations of same version found") ) -// DB ... +// DB wraps the pgxpool.Pool. type DB struct { *pgxpool.Pool } diff --git a/app/db_migrations/20260412145324_add_cleaned_at_to_users.sql b/app/db_migrations/20260412145324_add_cleaned_at_to_users.sql new file mode 100644 index 0000000..6f87c6a --- /dev/null +++ b/app/db_migrations/20260412145324_add_cleaned_at_to_users.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN cleaned_at TIMESTAMPTZ; \ No newline at end of file diff --git a/app/ds/user.go b/app/ds/user.go index 7bd7d50..4c44e8c 100644 --- a/app/ds/user.go +++ b/app/ds/user.go @@ -2,9 +2,13 @@ package ds import ( "context" + "encoding/json" "time" ) +// DeletedUsername is the placeholder username for soft-deleted users. +const DeletedUsername = "deleted" + const ( userCtxKey ctxKey = "user" @@ -12,7 +16,7 @@ const ( CleanupDeletedUserAfter = 30 * 24 * time.Hour ) -// User ... +// User represents a user account in the system. type User struct { ID ID `json:"id"` Username string `json:"username"` @@ -22,6 +26,7 @@ type User struct { CreatedAt time.Time `json:"-"` UpdatedAt *time.Time `json:"-"` DeletedAt *time.Time `json:"-"` + CleanedAt *time.Time `json:"-"` // IsAdmin is true if the user ID is listed in "admins" key in config file. // This field is set by the auth middleware. @@ -29,6 +34,18 @@ type User struct { IsAdmin bool `json:"-"` } +// MarshalJSON implements custom JSON serialization for User. +func (u *User) MarshalJSON() ([]byte, error) { + type Alias User + a := Alias(*u) + + if u.Deleted() { + a.Username = DeletedUsername + } + + return json.Marshal(&a) +} + // UsersFilter is used to filter and paginate user queries. type UsersFilter struct { Page int @@ -39,9 +56,10 @@ type UsersFilter struct { Deleted bool OrderBy string OrderDirection string + NotCleaned bool } -// Deleted ... +// Deleted reports whether the user has been soft-deleted. func (u *User) Deleted() bool { return u.DeletedAt != nil } diff --git a/app/errors.go b/app/errors.go index 203f15d..ea3d1ab 100644 --- a/app/errors.go +++ b/app/errors.go @@ -24,6 +24,9 @@ const ( // CodeInternal corresponds to HTTP 500 Internal Server Error. CodeInternal = http.StatusInternalServerError + + // CodeTooManyRequests corresponds to HTTP 429 Too Many Requests Error. + CodeTooManyRequests = http.StatusTooManyRequests ) // Error is a custom application error type that includes an HTTP status code @@ -147,3 +150,9 @@ func ErrUnauthorized() error { func ErrForbidden(message string) error { return NewError(CodeForbidden, message) } + +// ErrTooManyRequests is a convenience function to create a new Error with +// the CodeTooManyRequests (HTTP 429) status. +func ErrTooManyRequests(message string, params ...any) error { + return NewError(CodeTooManyRequests, message, params...) +} diff --git a/app/repo/email_confirmation_repo.go b/app/repo/email_confirmation_repo.go index bd492e6..19a3df7 100644 --- a/app/repo/email_confirmation_repo.go +++ b/app/repo/email_confirmation_repo.go @@ -10,8 +10,8 @@ import ( ) var ( - // ErrEmailConfirmationFound is a sentinel error returned when ds.EmailConfirmation not found. - ErrEmailConfirmationFound = app.ErrNotFound("email confirmation not found") + // ErrEmailConfirmationNotFound is a sentinel error returned when ds.EmailConfirmation not found. + ErrEmailConfirmationNotFound = app.ErrNotFound("email confirmation not found") ) // GetEmailConfirmationByCode retrieves an email confirmation record from the database @@ -28,7 +28,7 @@ func (r *Repo) GetEmailConfirmationByCode(ctx context.Context, code string) (ec ) if noRows(err) { ec = nil - err = ErrEmailConfirmationFound + err = ErrEmailConfirmationNotFound } return @@ -72,3 +72,26 @@ func (r *Repo) DeleteEmailConfirmationByUser(ctx context.Context, userID ds.ID) return r.exec(ctx, "DELETE FROM email_confirmations WHERE user_id = $1", userID) } + +// GetLatestEmailConfirmationByUserID returns the most recent email confirmation record for the given user. +func (r *Repo) GetLatestEmailConfirmationByUserID(ctx context.Context, userID ds.ID) (*ds.EmailConfirmation, error) { + ctx, span := r.tracer.Start(ctx, "GetLatestEmailConfirmationByUserID") + defer span.End() + + ec := new(ds.EmailConfirmation) + err := pgxscan.Get(ctx, r.db, ec, + `SELECT * FROM email_confirmations + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + userID, + ) + if noRows(err) { + return nil, ErrEmailConfirmationNotFound + } + if err != nil { + return nil, err + } + + return ec, nil +} diff --git a/app/repo/repo.go b/app/repo/repo.go index 084805c..0238e2c 100644 --- a/app/repo/repo.go +++ b/app/repo/repo.go @@ -321,6 +321,14 @@ func (b *filterBuilder) where(column string, val any) *filterBuilder { return b } +func (b *filterBuilder) whereIf(cond bool, column string, val any) *filterBuilder { + if cond { + b.where(column, val) + } + + return b +} + func (b *filterBuilder) whereRaw(steak string, seasoning ...any) *filterBuilder { if steak == "" { return b diff --git a/app/repo/user_repo.go b/app/repo/user_repo.go index 74a60a0..8003896 100644 --- a/app/repo/user_repo.go +++ b/app/repo/user_repo.go @@ -3,6 +3,7 @@ package repo import ( "context" "errors" + "fmt" "time" "github.com/georgysavva/scany/v2/pgxscan" @@ -131,16 +132,7 @@ func (r *Repo) DeleteUser(ctx context.Context, userID ds.ID) (err error) { return r.delete(ctx, "users", userID) } -// HardDeleteUser permanently deletes a user. -// This is not a logout. See you at the Afterlife, V. -func (r *Repo) HardDeleteUser(ctx context.Context, userID ds.ID) (err error) { - _, span := r.tracer.Start(ctx, "HardDeleteUser") - defer span.End() - - return r.hardDelete(ctx, "users", userID) -} - -// FilterUsers ... +// FilterUsers retrieves a paginated, filtered list of users from the database. func (r *Repo) FilterUsers(ctx context.Context, f ds.UsersFilter) (users []ds.User, count int, err error) { _, span := r.tracer.Start(ctx, "FilterUsers") defer span.End() @@ -150,9 +142,28 @@ func (r *Repo) FilterUsers(ctx context.Context, f ds.UsersFilter) (users []ds.Us createdAt(f.CreatedAt). deletedAt(f.DeletedAt). deleted(f.Deleted). + whereIf(f.NotCleaned, "cleaned_at IS NULL", nil). order(f.OrderBy, f.OrderDirection). withCount(f.WithCount). scan(ctx, &users) return } + +// UpdateUser updates user fields in the database. +func (r *Repo) UpdateUser(ctx context.Context, u *ds.User) error { + _, span := r.tracer.Start(ctx, "UpdateUser") + defer span.End() + + err := r.update(ctx, u.ID, "users", data{ + "email": u.Email, + "username": u.Username, + "password": u.Password, + "cleaned_at": u.CleanedAt, + }) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + + return nil +} diff --git a/app/service/email_confirmation_service.go b/app/service/email_confirmation_service.go new file mode 100644 index 0000000..2edde6c --- /dev/null +++ b/app/service/email_confirmation_service.go @@ -0,0 +1,227 @@ +package service + +import ( + "context" + "errors" + "math/rand" + "strings" + "time" + + z "github.com/Oudwins/zog" + "github.com/gopl-dev/server/app" + "github.com/gopl-dev/server/app/ds" + "github.com/gopl-dev/server/app/repo" + "github.com/gopl-dev/server/email" +) + +const emailConfirmationRetryAfterTolerance = 15 * time.Second + +var ( + // ErrInvalidConfirmationCode is the specific error returned + // when an email confirmation code is invalid or expired. + ErrInvalidConfirmationCode = app.InputError{"code": "Invalid confirmation code"} + + // ErrEmailAlreadyConfirmed is returned when the user's email is already confirmed. + ErrEmailAlreadyConfirmed = app.ErrUnprocessable("email already confirmed") + + // ErrResendConfirmationEmailCodeTooManyRequest is returned when the user requests a new confirmation email too soon. + ErrResendConfirmationEmailCodeTooManyRequest = app.ErrTooManyRequests("We already sent you confirmation email recently.") +) + +var createEmailConfirmationInputRules = z.Shape{ + "UserID": ds.IDInputRules, +} + +const ( + emailConfirmationTTL = time.Hour * 24 + emailConfirmationRetryAfter = time.Minute * 5 + emailConfirmationCodeLen = 6 +) + +// ConfirmEmail confirms an email address by validating the provided code, +// setting the email_confirmed flag for the associated user, and then deleting the used confirmation record. +func (s *Service) ConfirmEmail(ctx context.Context, code string) (err error) { + ctx, span := s.tracer.Start(ctx, "ConfirmEmail") + defer span.End() + + in := &ConfirmEmailInput{Code: code} + err = Normalize(in) + if err != nil { + return + } + + ec, err := s.db.GetEmailConfirmationByCode(ctx, in.Code) + if errors.Is(err, repo.ErrEmailConfirmationNotFound) { + return ErrInvalidConfirmationCode + } + if err != nil { + return err + } + + if ec.Invalid() { + return ErrInvalidConfirmationCode + } + + err = s.db.SetUserEmailConfirmed(ctx, ec.UserID) + if err != nil { + return + } + + err = s.db.DeleteEmailConfirmation(ctx, ec.ID) + if err != nil { + return + } + + user, err := s.GetUserByID(ctx, ec.UserID) + if err != nil { + return + } + + return s.LogEmailConfirmed(ctx, user.Email, user.ID) +} + +// ResendConfirmationEmailCode sends a new confirmation email to the authenticated user. +func (s *Service) ResendConfirmationEmailCode(ctx context.Context) (retryAfter time.Duration, err error) { + ctx, span := s.tracer.Start(ctx, "ResendConfirmationEmailCode") + defer span.End() + + user := ds.UserFromContext(ctx) + if user == nil { + err = app.ErrUnauthorized() + return + } + + if user.EmailConfirmed { + err = ErrEmailAlreadyConfirmed + return + } + + retryAfter, err = s.GetConfirmationEmailRetryAfter(ctx, user.ID) + if err != nil { + return + } + if retryAfter > emailConfirmationRetryAfterTolerance { + err = ErrResendConfirmationEmailCodeTooManyRequest + return + } + + emailConfirmCode, err := s.CreateEmailConfirmation(ctx, user.ID) + if err != nil { + return + } + + err = email.Send(user.Email, email.ConfirmEmail{ + Username: user.Username, + Email: user.Email, + Code: emailConfirmCode, + }) + + return +} + +// GetConfirmationEmailRetryAfter returns the remaining cooldown duration before the user can request a new confirmation email. +func (s *Service) GetConfirmationEmailRetryAfter(ctx context.Context, userID ds.ID) (retryAfter time.Duration, err error) { + ctx, span := s.tracer.Start(ctx, "GetConfirmationEmailRetryAfter") + defer span.End() + + ec, err := s.db.GetLatestEmailConfirmationByUserID(ctx, userID) + if errors.Is(err, repo.ErrEmailConfirmationNotFound) { + return 0, nil + } + if err != nil || ec == nil { + return 0, err + } + + resendAt := ec.CreatedAt.Add(emailConfirmationRetryAfter) + if time.Now().Before(resendAt) { + retryAfter = time.Until(resendAt) + } + + return retryAfter, nil +} + +// ConfirmEmailInput defines the input for email confirmation. +type ConfirmEmailInput struct { + Code string +} + +// Sanitize trims whitespace from the confirmation code. +func (in *ConfirmEmailInput) Sanitize() { + in.Code = strings.TrimSpace(in.Code) +} + +// Validate validates the email confirmation input against defined rules. +func (in *ConfirmEmailInput) Validate() error { + return validateInput(confirmEmailInputRules, in) +} + +// CreateEmailConfirmation creates a new ds.EmailConfirmation for given user. +func (s *Service) CreateEmailConfirmation(ctx context.Context, userID ds.ID) (code string, err error) { + ctx, span := s.tracer.Start(ctx, "CreateEmailConfirmation") + defer span.End() + + in := &CreateEmailConfirmationInput{UserID: userID} + err = Normalize(in) + if err != nil { + return + } + + code, err = s.newEmailConfirmationCode(ctx) + if err != nil { + return + } + + ec := &ds.EmailConfirmation{ + UserID: in.UserID, + Code: code, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(emailConfirmationTTL), + } + + err = s.db.CreateEmailConfirmation(ctx, ec) + return +} + +// CreateEmailConfirmationInput defines the input for creating an email confirmation. +type CreateEmailConfirmationInput struct { + UserID ds.ID +} + +// Sanitize performs no sanitization for this input. +func (in *CreateEmailConfirmationInput) Sanitize() { +} + +// Validate validates the email confirmation input against defined rules. +func (in *CreateEmailConfirmationInput) Validate() error { + return validateInput(createEmailConfirmationInputRules, in) +} + +// newEmailConfirmationCode generates a unique email confirmation code. +// It checks for collisions and increments the code length if necessary. +func (s *Service) newEmailConfirmationCode(ctx context.Context) (string, error) { + chars := []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ") + + length := emailConfirmationCodeLen + newCode := func(length int) string { + token := make([]byte, length) + for i := range length { + token[i] = chars[rand.Intn(len(chars))] //nolint:gosec + } + + return string(token) + } + + for { + code := newCode(length) + + _, err := s.db.GetEmailConfirmationByCode(ctx, code) + if errors.Is(err, repo.ErrEmailConfirmationNotFound) { + return code, nil + } + if err != nil { + return "", err + } + + length++ + } +} diff --git a/app/service/user_service.go b/app/service/user_service.go index 1d636cc..25ff31b 100644 --- a/app/service/user_service.go +++ b/app/service/user_service.go @@ -3,12 +3,12 @@ package service import ( "context" "errors" - "math/rand" "regexp" "strings" "time" z "github.com/Oudwins/zog" + "github.com/google/uuid" "github.com/gopl-dev/server/app" "github.com/gopl-dev/server/app/ds" "github.com/gopl-dev/server/app/repo" @@ -41,10 +41,6 @@ var createChangeEmailRequestInputRules = z.Shape{ "NewEmail": emailInputRules, } -var createEmailConfirmationInputRules = z.Shape{ - "UserID": ds.IDInputRules, -} - var createOAuthUserAccountInputRules = z.Shape{ "UserID": ds.IDInputRules, "Provider": provider.TypeInputRules, @@ -130,14 +126,9 @@ const ( passwordResetTokenLength = 32 emailChangeTokenLength = 32 - emailConfirmationTTL = time.Hour * 24 - emailConfirmationCodeLen = 6 ) var ( - // ErrInvalidConfirmationCode is the specific error returned - // when an email confirmation code is invalid or expired. - ErrInvalidConfirmationCode = app.InputError{"code": "Invalid confirmation code"} // ErrInvalidPasswordResetToken ... ErrInvalidPasswordResetToken = app.ErrUnprocessable("password reset request is either expired or invalid") @@ -280,63 +271,6 @@ func (in *ChangeUsernameInput) Validate() error { return validateInput(changeUsernameInputRules, in) } -// ConfirmEmail confirms an email address by validating the provided code, -// setting the email_confirmed flag for the associated user, and then deleting the used confirmation record. -func (s *Service) ConfirmEmail(ctx context.Context, code string) (err error) { - ctx, span := s.tracer.Start(ctx, "ConfirmEmail") - defer span.End() - - in := &ConfirmEmailInput{Code: code} - err = Normalize(in) - if err != nil { - return - } - - ec, err := s.db.GetEmailConfirmationByCode(ctx, in.Code) - if errors.Is(err, repo.ErrEmailConfirmationFound) { - return ErrInvalidConfirmationCode - } - if err != nil { - return err - } - - if ec.Invalid() { - return ErrInvalidConfirmationCode - } - - err = s.db.SetUserEmailConfirmed(ctx, ec.UserID) - if err != nil { - return - } - - err = s.db.DeleteEmailConfirmation(ctx, ec.ID) - if err != nil { - return - } - - user, err := s.GetUserByID(ctx, ec.UserID) - if err != nil { - return - } - - return s.LogEmailConfirmed(ctx, user.Email, user.ID) -} - -// ConfirmEmailInput defines the input for email confirmation. -type ConfirmEmailInput struct { - Code string -} - -// Sanitize trims whitespace from the confirmation code. -func (in *ConfirmEmailInput) Sanitize() { - in.Code = strings.TrimSpace(in.Code) -} - -// Validate validates the email confirmation input against defined rules. -func (in *ConfirmEmailInput) Validate() error { - return validateInput(confirmEmailInputRules, in) -} - // ConfirmEmailChange handles the logic for finalizing an email change via a token. func (s *Service) ConfirmEmailChange(ctx context.Context, token string) (err error) { ctx, span := s.tracer.Start(ctx, "ConfirmEmailChange") @@ -473,77 +407,6 @@ func (in *CreateChangeEmailRequestInput) Validate() error { return validateInput(createChangeEmailRequestInputRules, in) } -// CreateEmailConfirmation creates a new ds.EmailConfirmation for given user. -func (s *Service) CreateEmailConfirmation(ctx context.Context, userID ds.ID) (code string, err error) { - ctx, span := s.tracer.Start(ctx, "CreateEmailConfirmation") - defer span.End() - - in := &CreateEmailConfirmationInput{UserID: userID} - err = Normalize(in) - if err != nil { - return - } - - code, err = s.newEmailConfirmationCode(ctx) - if err != nil { - return - } - - ec := &ds.EmailConfirmation{ - UserID: in.UserID, - Code: code, - CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(emailConfirmationTTL), - } - - err = s.db.CreateEmailConfirmation(ctx, ec) - return -} - -// newEmailConfirmationCode generates a unique email confirmation code. -// It checks for collisions and increments the code length if necessary. -func (s *Service) newEmailConfirmationCode(ctx context.Context) (string, error) { - chars := []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ") - - length := emailConfirmationCodeLen - newCode := func(length int) string { - token := make([]byte, length) - for i := range length { - token[i] = chars[rand.Intn(len(chars))] //nolint:gosec - } - - return string(token) - } - - for { - code := newCode(length) - - _, err := s.db.GetEmailConfirmationByCode(ctx, code) - if errors.Is(err, repo.ErrEmailConfirmationFound) { - return code, nil - } - if err != nil { - return "", err - } - - length++ - } -} - -// CreateEmailConfirmationInput defines the input for creating an email confirmation. -type CreateEmailConfirmationInput struct { - UserID ds.ID -} - -// Sanitize performs no sanitization for this input. -func (in *CreateEmailConfirmationInput) Sanitize() { -} - -// Validate validates the email confirmation input against defined rules. -func (in *CreateEmailConfirmationInput) Validate() error { - return validateInput(createEmailConfirmationInputRules, in) -} - // CreateOAuthUserAccount creates a new user session object. func (s *Service) CreateOAuthUserAccount(ctx context.Context, m *ds.OAuthUserAccount) (err error) { ctx, span := s.tracer.Start(ctx, "CreateOAuthUserAccount") @@ -970,11 +833,16 @@ func (in *GetUserAndSessionFromJWTInput) Validate() error { return validateInput(getUserAndSessionFromJWTInputRules, in) } -// HardDeleteUser handles the logic for deleting a user account and relations. -func (s *Service) HardDeleteUser(ctx context.Context, userID ds.ID) (err error) { - ctx, span := s.tracer.Start(ctx, "DeleteUser") +// CleanupDeletedUser handles the logic for deleting a user account and relations. +func (s *Service) CleanupDeletedUser(ctx context.Context, userID ds.ID) (err error) { + ctx, span := s.tracer.Start(ctx, "CleanupDeletedUser") defer span.End() + user, err := s.GetUserByID(ctx, userID) + if err != nil { + return + } + // sessions err = s.db.DeleteSessionsByUserID(ctx, userID) if err != nil { @@ -999,8 +867,12 @@ func (s *Service) HardDeleteUser(ctx context.Context, userID ds.ID) (err error) return } - // user - return s.db.HardDeleteUser(ctx, userID) + user.Email = "deleted-" + random.String(16) + "-" + uuid.NewString() //nolint:mnd + user.Username = "deleted-" + random.String(16) + "-" + uuid.NewString() //nolint:mnd + user.Password = "deleted-" + random.String(16) //nolint:mnd + user.CleanedAt = new(time.Now()) + + return s.db.UpdateUser(ctx, user) } // HardDeleteUserInput defines the input for hard deleting a user. diff --git a/cmd/cli/commands/seed_data.go b/cmd/cli/commands/seed_data.go index 77cae39..2f12c01 100644 --- a/cmd/cli/commands/seed_data.go +++ b/cmd/cli/commands/seed_data.go @@ -18,7 +18,7 @@ var seedAvailableData = []string{ "all", "users", "books", } -// NewSeedDataCmd ... +// NewSeedDataCmd returns a CLI command to seed the database with test data. func NewSeedDataCmd() cli.Command { return cli.Command{ Name: "seed_data", diff --git a/cmd/setup_wizard/main.go b/cmd/setup_wizard/main.go index 32b6ec8..7f0a3e0 100644 --- a/cmd/setup_wizard/main.go +++ b/cmd/setup_wizard/main.go @@ -743,7 +743,7 @@ func writeTestConfigs(m swModel, src []byte) error { vals = append(vals, yv("db", "name", testDBName), yv("email", "driver", "test"), - yv("tracing", "enabled", "false"), // Обратите внимание: в вашем коде была строка "false" + yv("tracing", "enabled", false), yv("files", "storage_driver", "in-memory-fs"), ) @@ -796,9 +796,9 @@ func ensureTestDB(hostPort, user, pass, dbName string) error { func main() { fmt.Println(swErrorStyle.Render("⚠ WARNING")) - fmt.Println(swErrorStyle.Render(" This wizard is intended for local development setup only.")) - fmt.Println(swErrorStyle.Render(" It will create or overwrite .config.yaml and may create databases.")) - fmt.Println(swErrorStyle.Render(" Do NOT run this in staging or production environments.")) + fmt.Println(swErrorStyle.Render(" This wizard is intended for LOCAL DEVELOPMENT setup only.")) + fmt.Println(swErrorStyle.Render(" It may create and/or overwrite files and databases.")) + fmt.Println(swErrorStyle.Render(" DO NOT PROCEED UNLESS you have mastered the \"First-Backup-Then-Fire\" spell.")) fmt.Println() fmt.Print(" Proceed? [Y/n]: ") var ans string diff --git a/frontend/component/form.templ b/frontend/component/form.templ index 6bd1feb..ba74373 100644 --- a/frontend/component/form.templ +++ b/frontend/component/form.templ @@ -161,6 +161,7 @@ type FileUploadInputParams struct { FileIDModel string // "form.cover_file_id" Accept string Preview bool + Description string } templ FileUploadInput(p FileUploadInputParams) { @@ -177,6 +178,10 @@ templ FileUploadInput(p FileUploadInputParams) { :disabled="upload.uploading || submitting" /> + if p.Description != "" { +
{ p.Description }
+ } +
Selected:
diff --git a/frontend/component/form_templ.go b/frontend/component/form_templ.go index 0ff1b20..c0d9dad 100644 --- a/frontend/component/form_templ.go +++ b/frontend/component/form_templ.go @@ -762,6 +762,7 @@ type FileUploadInputParams struct { FileIDModel string // "form.cover_file_id" Accept string Preview bool + Description string } func FileUploadInput(p FileUploadInputParams) templ.Component { @@ -792,7 +793,7 @@ func FileUploadInput(p FileUploadInputParams) templ.Component { var templ_7745c5c3_Var40 string templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/component/form.templ`, Line: 169, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/component/form.templ`, Line: 170, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { @@ -805,7 +806,7 @@ func FileUploadInput(p FileUploadInputParams) templ.Component { var templ_7745c5c3_Var41 string templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Accept) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/component/form.templ`, Line: 174, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/component/form.templ`, Line: 175, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { @@ -818,101 +819,124 @@ func FileUploadInput(p FileUploadInputParams) templ.Component { var templ_7745c5c3_Var42 string templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs("upload.upload($event)") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/component/form.templ`, Line: 176, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/component/form.templ`, Line: 177, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" :disabled=\"upload.uploading || submitting\">
Selected:
Uploading...

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.FileIDModel + " !== ''") + if p.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/component/form.templ`, Line: 182, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
Selected:
Uploading...

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if p.Preview { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" alt=\"Preview\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
Remove
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -936,12 +960,12 @@ func TopicPicker() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var49 := templ.GetChildren(ctx) - if templ_7745c5c3_Var49 == nil { - templ_7745c5c3_Var49 = templ.NopComponent + templ_7745c5c3_Var50 := templ.GetChildren(ctx) + if templ_7745c5c3_Var50 == nil { + templ_7745c5c3_Var50 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/frontend/page/confirm_email.templ b/frontend/page/confirm_email.templ index 785d40f..08400a0 100644 --- a/frontend/page/confirm_email.templ +++ b/frontend/page/confirm_email.templ @@ -1,12 +1,11 @@ package page import ( - . "github.com/gopl-dev/server/frontend/component" - "github.com/gopl-dev/server/frontend/component/icon" +. "github.com/gopl-dev/server/frontend/component" ) -templ ConfirmEmailForm() { +templ ConfirmEmailForm(retryAfterSeconds int) {

Confirm email

-
+
@Form("confirmEmailForm") {

- + +
+
+
+ If you didn't receive our email, you can request a new code: +
+ +
+ Don't forget to check your Spam folder. If you still don't receive it after a few attempts, please contact us. +
+
+
+ New email with confirmation code has been sent. +
+
}
-} +} \ No newline at end of file diff --git a/frontend/page/confirm_email_templ.go b/frontend/page/confirm_email_templ.go index acc0fb3..fa8f693 100644 --- a/frontend/page/confirm_email_templ.go +++ b/frontend/page/confirm_email_templ.go @@ -10,10 +10,9 @@ import templruntime "github.com/a-h/templ/runtime" import ( . "github.com/gopl-dev/server/frontend/component" - "github.com/gopl-dev/server/frontend/component/icon" ) -func ConfirmEmailForm() templ.Component { +func ConfirmEmailForm(retryAfterSeconds int) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -34,11 +33,23 @@ func ConfirmEmailForm() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Confirm email

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Confirm email

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -50,15 +61,7 @@ func ConfirmEmailForm() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

We have sent a confirmation code to your email, please enter it here:
Your email has been confirmed!
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -81,13 +84,13 @@ func ConfirmEmailForm() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
If you didn't receive our email, you can request a new code:
Don't forget to check your Spam folder. If you still don't receive it after a few attempts, please contact us.
New email with confirmation code has been sent.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Form("confirmEmailForm").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Form("confirmEmailForm").Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/frontend/page/create_book.templ b/frontend/page/create_book.templ index 4103984..8ac2c83 100644 --- a/frontend/page/create_book.templ +++ b/frontend/page/create_book.templ @@ -165,6 +165,7 @@ templ CreateBookForm() { FileIDModel: "form.cover_file_id", Accept: "image/*", Preview: true, + Description: "Only PNG | JPG. Max dimensions: 3000×3000px, size up to 20MB", }) @TopicPicker() diff --git a/frontend/page/create_book_templ.go b/frontend/page/create_book_templ.go index c726fe0..5b1611d 100644 --- a/frontend/page/create_book_templ.go +++ b/frontend/page/create_book_templ.go @@ -61,6 +61,7 @@ func CreateBookForm() templ.Component { FileIDModel: "form.cover_file_id", Accept: "image/*", Preview: true, + Description: "Only PNG | JPG. Max dimensions: 3000×3000px, size up to 20MB", }).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err diff --git a/frontend/page/edit_book.templ b/frontend/page/edit_book.templ index ee30cbf..9d84e00 100644 --- a/frontend/page/edit_book.templ +++ b/frontend/page/edit_book.templ @@ -266,6 +266,7 @@ templ EditBookForm(bookID string) { FileIDModel: "form.cover_file_id", Accept: "image/*", Preview: true, + Description: "Only PNG | JPG. Max dimensions: 3000×3000px, size up to 20MB", })
diff --git a/frontend/page/edit_book_templ.go b/frontend/page/edit_book_templ.go index 2c3017a..84e0f64 100644 --- a/frontend/page/edit_book_templ.go +++ b/frontend/page/edit_book_templ.go @@ -73,6 +73,7 @@ func EditBookForm(bookID string) templ.Component { FileIDModel: "form.cover_file_id", Accept: "image/*", Preview: true, + Description: "Only PNG | JPG. Max dimensions: 3000×3000px, size up to 20MB", }).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err diff --git a/frontend/page/user_sign_up.templ b/frontend/page/user_sign_up.templ index b174de4..f41828d 100644 --- a/frontend/page/user_sign_up.templ +++ b/frontend/page/user_sign_up.templ @@ -19,7 +19,8 @@ templ UserSignUpForm() { submit: async function () { const {resp, data} = await HTTP.postJSON('/api/users/sign-up/', this.form) - if (data?.success === true || resp.status === 200) { + if (data?.token) { + localStorage.setItem('auth_token', data.token) window.location.href = '/users/confirm-email/' return } diff --git a/frontend/page/user_sign_up_templ.go b/frontend/page/user_sign_up_templ.go index 9a3f0c1..1ff3f68 100644 --- a/frontend/page/user_sign_up_templ.go +++ b/frontend/page/user_sign_up_templ.go @@ -31,7 +31,7 @@ func UserSignUpForm() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Sign Up

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Sign Up

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/server/docs/docs.go b/server/docs/docs.go index a9344cb..33f3658 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -1181,19 +1181,8 @@ const docTemplate = `{ "tags": [ "users" ], - "summary": "Confirm email", - "operationId": "ConfirmEmail", - "parameters": [ - { - "description": "Request body", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.ConfirmEmail" - } - } - ], + "summary": "SendE email confirmation code", + "operationId": "SendEmailConfirmationCode", "responses": { "200": { "description": "OK", @@ -1553,7 +1542,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Status" + "$ref": "#/definitions/response.UserSignIn" } }, "422": { diff --git a/server/docs/swagger.json b/server/docs/swagger.json index 259694b..076f416 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -1170,19 +1170,8 @@ "tags": [ "users" ], - "summary": "Confirm email", - "operationId": "ConfirmEmail", - "parameters": [ - { - "description": "Request body", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/request.ConfirmEmail" - } - } - ], + "summary": "SendE email confirmation code", + "operationId": "SendEmailConfirmationCode", "responses": { "200": { "description": "OK", @@ -1542,7 +1531,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Status" + "$ref": "#/definitions/response.UserSignIn" } }, "422": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index ae3afe1..1e785e4 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -1164,14 +1164,7 @@ paths: post: consumes: - application/json - operationId: ConfirmEmail - parameters: - - description: Request body - in: body - name: request - required: true - schema: - $ref: '#/definitions/request.ConfirmEmail' + operationId: SendEmailConfirmationCode produces: - application/json responses: @@ -1189,7 +1182,7 @@ paths: $ref: '#/definitions/handler.Error' security: - ApiKeyAuth: [] - summary: Confirm email + summary: SendE email confirmation code tags: - users /users/email/: @@ -1401,7 +1394,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Status' + $ref: '#/definitions/response.UserSignIn' "422": description: Unprocessable Entity schema: diff --git a/server/endpoint/protected_api_endpoints.go b/server/endpoint/protected_api_endpoints.go index 4de3bc8..5c41697 100644 --- a/server/endpoint/protected_api_endpoints.go +++ b/server/endpoint/protected_api_endpoints.go @@ -2,6 +2,10 @@ package endpoint // ProtectedAPIEndpoints registers API routes that require authentication. func (r *Router) ProtectedAPIEndpoints() { + r.POST("/users/email-confirmation-code/", r.handler.SendEmailConfirmationCode) + + r.Use(r.mw.EmailMustBeConfirmed) + // users r.PUT("/users/password/", r.handler.ChangePassword) r.POST("/users/email/", r.handler.RequestEmailChange) diff --git a/server/endpoint/protected_web_endpoints.go b/server/endpoint/protected_web_endpoints.go index 91fe4b3..38cbaa6 100644 --- a/server/endpoint/protected_web_endpoints.go +++ b/server/endpoint/protected_web_endpoints.go @@ -2,8 +2,11 @@ package endpoint // ProtectedWebEndpoints registers web endpoints that require user authentication. func (r *Router) ProtectedWebEndpoints() { - // users r.GET("/users/sign-out/", r.handler.UserSignOut) + r.GET("/users/confirm-email/", r.handler.ConfirmEmailView) + + r.Use(r.mw.EmailMustBeConfirmed) + // users r.GET("/users/settings/", r.handler.UserSettingsView) r.GET("/change-password/", r.handler.ChangePasswordView) r.GET("/change-email/", r.handler.RequestEmailChangeView) diff --git a/server/endpoint/public_web_endpoints.go b/server/endpoint/public_web_endpoints.go index 12e9b70..6f32147 100644 --- a/server/endpoint/public_web_endpoints.go +++ b/server/endpoint/public_web_endpoints.go @@ -7,7 +7,6 @@ func (r *Router) PublicWebEndpoints() { // User authentication and registration r.GET("/users/sign-up/", r.handler.UserSignUpView) r.GET("/users/sign-in/", r.handler.UserSignInView) - r.GET("/users/confirm-email/", r.handler.ConfirmEmailView) r.GET("/password-reset/", r.handler.PasswordResetRequestView) r.GET("/password-reset/{token}/", r.handler.PasswordResetConfirmView) diff --git a/server/handler/dashboard_handler.go b/server/handler/dashboard_handler.go index 89c5ee4..bf5bd43 100644 --- a/server/handler/dashboard_handler.go +++ b/server/handler/dashboard_handler.go @@ -21,7 +21,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { return } - renderTempl(ctx, w, layout.Dashboard(layout.Data{ + RenderTempl(ctx, w, layout.Dashboard(layout.Data{ Title: "Dashboard", Body: page.Home(page.HomeData{}), User: frontend.NewUser(ds.UserFromContext(r.Context())), diff --git a/server/handler/handler.go b/server/handler/handler.go index a4e381b..57bb20f 100644 --- a/server/handler/handler.go +++ b/server/handler/handler.go @@ -252,9 +252,9 @@ func jsonCreated(w http.ResponseWriter, body any) { } } -// renderTempl renders a templ.Component to the http.ResponseWriter, setting the +// RenderTempl renders a templ.Component to the http.ResponseWriter, setting the // Content-Type to HTML and the status to 200 OK. -func renderTempl(ctx context.Context, w http.ResponseWriter, t templ.Component) { +func RenderTempl(ctx context.Context, w http.ResponseWriter, t templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) @@ -274,13 +274,13 @@ func RenderDefaultLayout(ctx context.Context, w http.ResponseWriter, data layout data.User = frontend.NewUser(ds.UserFromContext(ctx)) t := layout.Default(data) - renderTempl(ctx, w, t) + RenderTempl(ctx, w, t) } // RenderUserSignInPage renders the HTML page containing the user sign-in form, // optionally specifying a redirect-to path after successful login. func RenderUserSignInPage(w http.ResponseWriter, r *http.Request, redirectTo string) { - renderTempl(r.Context(), w, layout.Default(layout.Data{ + RenderTempl(r.Context(), w, layout.Default(layout.Data{ Title: "Sign In", Body: page.UserSignInForm(redirectTo), })) diff --git a/server/handler/home_handler.go b/server/handler/home_handler.go index ff4bb5b..dfa90a0 100644 --- a/server/handler/home_handler.go +++ b/server/handler/home_handler.go @@ -35,7 +35,7 @@ func (h *Handler) Home(w http.ResponseWriter, r *http.Request) { title := random.Element([]string{ // Now that you have found a list of silly titles, please read through it. "Green Olives Party Late", - "Goats Organizing Pajama Launches", + "Goats Organizing Pajama Laundry", "Grandma’s Overpowered Pancake Love", "Galactic Octopus Pizza League", "Giraffes On Purple Ladders", @@ -51,7 +51,7 @@ func (h *Handler) Home(w http.ResponseWriter, r *http.Request) { }) data.Title = title - renderTempl(ctx, w, layout.Default(layout.Data{ + RenderTempl(ctx, w, layout.Default(layout.Data{ Title: "Welcome", Body: page.Home(data), User: frontend.NewUser(ds.UserFromContext(r.Context())), diff --git a/server/handler/password_reset_handler.go b/server/handler/password_reset_handler.go index e9ba0cc..3d04541 100644 --- a/server/handler/password_reset_handler.go +++ b/server/handler/password_reset_handler.go @@ -15,7 +15,7 @@ func (h *Handler) PasswordResetRequestView(w http.ResponseWriter, r *http.Reques ctx, span := h.tracer.Start(r.Context(), "PasswordResetRequestView") defer span.End() - renderTempl(ctx, w, layout.Default(layout.Data{ + RenderTempl(ctx, w, layout.Default(layout.Data{ Title: "Request Password Reset", Body: page.PasswordResetRequestForm(), })) diff --git a/server/handler/user_handler.go b/server/handler/user_handler.go index e26d6ed..e7c35da 100644 --- a/server/handler/user_handler.go +++ b/server/handler/user_handler.go @@ -4,7 +4,9 @@ import ( "errors" "log" "net/http" + "strconv" + "github.com/gopl-dev/server/app" "github.com/gopl-dev/server/app/ds" "github.com/gopl-dev/server/app/service" "github.com/gopl-dev/server/frontend/layout" @@ -22,7 +24,7 @@ import ( // @Accept json // @Produce json // @Param request body request.UserSignUp true "Request body" -// @Success 200 {object} response.Status +// @Success 200 {object} response.UserSignIn // @Failure 422 {object} Error // @Failure 500 {object} Error // @Router /users/sign-up/ [post] @@ -44,7 +46,19 @@ func (h *Handler) UserSignUp(w http.ResponseWriter, r *http.Request) { return } - res.jsonSuccess() + user, token, err := h.service.AuthenticateUser(ctx, req.Email, req.Password) + if err != nil { + res.Abort(err) + return + } + + setSessionCookie(w, token) + + res.jsonOK(response.UserSignIn{ + ID: user.ID, + Username: user.Username, + Token: token, + }) } // UserSignIn is the API handler for the user login endpoint. @@ -120,6 +134,34 @@ func (h *Handler) ConfirmEmail(w http.ResponseWriter, r *http.Request) { res.jsonSuccess() } +// SendEmailConfirmationCode sends a confirmation code to the authenticated user's email. +// +// @ID SendEmailConfirmationCode +// @Summary SendE email confirmation code +// @Tags users +// @Accept json +// @Produce json +// @Success 200 {object} response.Status +// @Failure 422 {object} Error +// @Failure 500 {object} Error +// @Router /users/confirm-email/ [post] +// @Security ApiKeyAuth +func (h *Handler) SendEmailConfirmationCode(w http.ResponseWriter, r *http.Request) { + ctx, span := h.tracer.Start(r.Context(), "SendEmailConfirmationCode") + defer span.End() + + retryAfter, err := h.service.ResendConfirmationEmailCode(ctx) + if errors.Is(err, service.ErrResendConfirmationEmailCodeTooManyRequest) { + w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds()))) + } + if err != nil { + Abort(w, r, err) + return + } + + jsonSuccess(w) +} + // UserSignUpView renders the static HTML form for user registration. func (h *Handler) UserSignUpView(w http.ResponseWriter, r *http.Request) { ctx, span := h.tracer.Start(r.Context(), "UserSignUpView") @@ -208,9 +250,21 @@ func (h *Handler) ConfirmEmailView(w http.ResponseWriter, r *http.Request) { ctx, span := h.tracer.Start(r.Context(), "ConfirmEmailView") defer span.End() + user := ds.UserFromContext(ctx) + if user == nil { + Abort(w, r, app.ErrUnauthorized()) + return + } + + retryAfter, err := h.service.GetConfirmationEmailRetryAfter(ctx, user.ID) + if err != nil { + Abort(w, r, err) + return + } + RenderDefaultLayout(ctx, w, layout.Data{ Title: "Confirm email", - Body: page.ConfirmEmailForm(), + Body: page.ConfirmEmailForm(int(retryAfter.Seconds())), }) } diff --git a/server/middleware/user_mw.go b/server/middleware/user_mw.go index df76863..8920164 100644 --- a/server/middleware/user_mw.go +++ b/server/middleware/user_mw.go @@ -6,6 +6,9 @@ import ( "github.com/gopl-dev/server/app" "github.com/gopl-dev/server/app/ds" + "github.com/gopl-dev/server/frontend" + "github.com/gopl-dev/server/frontend/layout" + "github.com/gopl-dev/server/frontend/page" "github.com/gopl-dev/server/server/handler" ) @@ -46,6 +49,7 @@ func (mw *Middleware) ResolveUserFromCookie(next handler.Fn) handler.Fn { func (mw *Middleware) UserAuth(next handler.Fn) handler.Fn { return func(w http.ResponseWriter, r *http.Request) { user := ds.UserFromContext(r.Context()) + if user == nil { if handler.ShouldServeJSON(r) { handler.Abort(w, r, app.ErrUnauthorized()) @@ -65,6 +69,40 @@ func (mw *Middleware) UserAuth(next handler.Fn) handler.Fn { } } +// EmailMustBeConfirmed blocks access for users with unconfirmed email addresses. +func (mw *Middleware) EmailMustBeConfirmed(next handler.Fn) handler.Fn { + return func(w http.ResponseWriter, r *http.Request) { + user := ds.UserFromContext(r.Context()) + + if user == nil { + handler.Abort(w, r, app.ErrUnauthorized()) + return + } + + if user.EmailConfirmed { + next(w, r) + return + } + + if handler.ShouldServeJSON(r) { + handler.Abort(w, r, app.ErrForbidden("email not confirmed")) + return + } + + retryAfter, err := mw.service.GetConfirmationEmailRetryAfter(r.Context(), user.ID) + if err != nil { + handler.Abort(w, r, err) + return + } + + handler.RenderTempl(r.Context(), w, layout.Default(layout.Data{ + Title: "Email confirmation required", + Body: page.ConfirmEmailForm(int(retryAfter.Seconds())), + User: frontend.NewUser(user), + })) + } +} + // AdminOnly is a middleware that restricts access to admin users only. func (mw *Middleware) AdminOnly(next handler.Fn) handler.Fn { return func(w http.ResponseWriter, r *http.Request) { diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..73c5d8f --- /dev/null +++ b/test/README.md @@ -0,0 +1,10 @@ +# How one can be sure that lovely 🌸🌸🌸 is not producing 💩💩💩 + +We utilize different testing layers: + +- **API (Integration Tests)**: These validate the request-response cycle and any resulting side effects. You send a payload, check the response, and verify the system behaves as expected. (See existing tests for examples). +- **Services**: These cover internal business logic that isn't always exposed via the API. For example, if you need to offboard users or clean up accounts based on specific criteria, test the logic here. +- **Workers**: Our background processes that run on a schedule. Here you can seed your data and manually trigger a job to make sure it actually does its thing. + +## Setup +Each of these layers requires a real database connection. Please refer to `../SETUP.md` for help with the test database and its specific configuration. \ No newline at end of file diff --git a/test/api_test/api_test.go b/test/api_test/api_test.go index 2f33d65..f99fcd2 100644 --- a/test/api_test/api_test.go +++ b/test/api_test/api_test.go @@ -228,7 +228,9 @@ func login(t *testing.T) *ds.User { return authUser } - user := create[ds.User](t) + user := create(t, ds.User{ + EmailConfirmed: true, + }) loginAs(t, user) return authUser @@ -253,8 +255,16 @@ func loginAs(t *testing.T, u *ds.User) (token string) { return token } -func makeAdmin(u *ds.User) { - app.Config().Admins = []string{u.ID.String()} +func loginAsAdmin(t *testing.T) (user *ds.User) { + t.Helper() + + user = create(t, ds.User{ + EmailConfirmed: true, + }) + loginAs(t, user) + + app.Config().Admins = []string{user.ID.String()} + return } type fileForm struct { diff --git a/test/api_test/book_test.go b/test/api_test/book_test.go index 2f55ae1..b3c7df3 100644 --- a/test/api_test/book_test.go +++ b/test/api_test/book_test.go @@ -274,8 +274,7 @@ func TestUpdateBook_WithReview(t *testing.T) { } func TestUpdateBook_WithoutReview(t *testing.T) { - user := login(t) - makeAdmin(user) + loginAsAdmin(t) imageBytes1, err := random.ImagePNG(10) test.CheckErr(t, err) @@ -347,8 +346,7 @@ func TestUpdateBook_WithoutReview(t *testing.T) { } func TestApproveNewBook(t *testing.T) { - user := login(t) - makeAdmin(user) + admin := loginAsAdmin(t) book := create(t, ds.Book{ Entity: &ds.Entity{ @@ -366,7 +364,7 @@ func TestApproveNewBook(t *testing.T) { }) test.AssertInDB(t, tt.DB, "event_logs", test.Data{ - "user_id": user.ID, + "user_id": admin.ID, "type": ds.EventLogEntityApproved, "entity_id": book.ID, "is_public": false, @@ -391,8 +389,7 @@ func TestApproveNewBook(t *testing.T) { } func TestRejectNewBook(t *testing.T) { - user := login(t) - makeAdmin(user) + admin := loginAsAdmin(t) book := create(t, ds.Book{ Entity: &ds.Entity{ @@ -413,7 +410,7 @@ func TestRejectNewBook(t *testing.T) { }) test.AssertInDB(t, tt.DB, "event_logs", test.Data{ - "user_id": user.ID, + "user_id": admin.ID, "type": ds.EventLogEntityRejected, "entity_id": book.ID, "meta": map[string]any{"note": req.Note}, @@ -432,8 +429,7 @@ func TestRejectNewBook(t *testing.T) { } func TestDeleteBook(t *testing.T) { - user := login(t) - makeAdmin(user) + loginAsAdmin(t) book := create[ds.Book](t) diff --git a/test/api_test/email_confirmation_test.go b/test/api_test/email_confirmation_test.go new file mode 100644 index 0000000..6591f82 --- /dev/null +++ b/test/api_test/email_confirmation_test.go @@ -0,0 +1,88 @@ +package api_test + +import ( + "context" + "net/http" + "testing" + + "github.com/gopl-dev/server/app" + "github.com/gopl-dev/server/app/ds" + "github.com/gopl-dev/server/server/handler" + "github.com/gopl-dev/server/server/request" + "github.com/gopl-dev/server/server/response" + "github.com/gopl-dev/server/test" + "github.com/stretchr/testify/assert" +) + +func TestResendEmailConfirmationCode(t *testing.T) { + user := create(t, ds.User{EmailConfirmed: false}) + loginAs(t, user) + + _, err := tt.DB.Exec(context.TODO(), "UPDATE users SET email_confirmed = false WHERE id = $1", user.ID) + test.CheckErr(t, err) + + var resp response.Status + Request(t, RequestArgs{ + method: http.MethodPost, + path: "/users/email-confirmation-code/", + bindResponse: &resp, + assertStatus: http.StatusOK, + }) + + vars := test.LoadEmailVars(t, user.Email) + + assert.Equal(t, user.Username, app.String(vars["username"])) + assert.Equal(t, user.Email, app.String(vars["email"])) + + test.AssertInDB(t, tt.DB, "email_confirmations", test.Data{ + "user_id": user.ID, + "code": vars["code"], + }) + + t.Run("too many attempts", func(t *testing.T) { + var resp handler.Error + Request(t, RequestArgs{ + method: http.MethodPost, + path: "/users/email-confirmation-code/", + bindResponse: &resp, + assertStatus: http.StatusTooManyRequests, + }) + }) +} + +func TestUserConfirmEmail(t *testing.T) { + ec := create[ds.EmailConfirmation](t) + + req := request.ConfirmEmail{ + Code: ec.Code, + } + + var resp response.Status + Request(t, RequestArgs{ + method: http.MethodPost, + path: "/users/confirm-email", + body: req, + bindResponse: &resp, + assertStatus: http.StatusOK, + }) + + test.AssertInDB(t, tt.DB, "users", test.Data{ + "id": ec.UserID, + "email_confirmed": true, + }) + + test.AssertNotInDB(t, tt.DB, "email_confirmations", test.Data{ + "code": ec.Code, + }) + + test.AssertInDB(t, tt.DB, "event_logs", test.Data{ + "user_id": ec.UserID, + "type": ds.EventLogUserEmailConfirmed, + "is_public": false, + }) + test.AssertInDB(t, tt.DB, "event_logs", test.Data{ + "user_id": ec.UserID, + "type": ds.EventLogUserAccountActivated, + "is_public": true, + }) +} diff --git a/test/api_test/entity_change_request_test.go b/test/api_test/entity_change_request_test.go index 7dfbc03..86105ce 100644 --- a/test/api_test/entity_change_request_test.go +++ b/test/api_test/entity_change_request_test.go @@ -16,9 +16,7 @@ import ( ) func TestGetChangeRequestDiff(t *testing.T) { - admin := create[ds.User](t) - loginAs(t, admin) - makeAdmin(admin) + _ = loginAsAdmin(t) _, err := factory.Ten(tt.Factory.CreateEntityChangeRequest, ds.EntityChangeRequest{ Status: ds.EntityChangePending, @@ -58,9 +56,7 @@ func TestFilterChangeRequest(t *testing.T) { } func TestRejectChangeRequest(t *testing.T) { - admin := create[ds.User](t) - loginAs(t, admin) - makeAdmin(admin) + admin := loginAsAdmin(t) user := create[ds.User](t) book := create[ds.Book](t) @@ -99,9 +95,7 @@ func TestRejectChangeRequest(t *testing.T) { } func TestApplyChangeRequestToBook(t *testing.T) { - admin := create[ds.User](t) - loginAs(t, admin) - makeAdmin(admin) + admin := loginAsAdmin(t) imageBytes, err := random.ImagePNG(10) test.CheckErr(t, err) @@ -243,9 +237,7 @@ func TestApplyChangeRequestToBook(t *testing.T) { } func TestApplyChangeRequestToPage(t *testing.T) { - admin := create[ds.User](t) - loginAs(t, admin) - makeAdmin(admin) + admin := loginAsAdmin(t) user := create[ds.User](t) diff --git a/test/api_test/page_test.go b/test/api_test/page_test.go index 347c384..edc243a 100644 --- a/test/api_test/page_test.go +++ b/test/api_test/page_test.go @@ -16,11 +16,8 @@ import ( ) func TestCreatePage(t *testing.T) { - // only admins can create pages now - user := create[ds.User](t) - token := loginAs(t, user) - - app.Config().Admins = []string{user.ID.String()} + // only admins can create pages for now + admin := loginAsAdmin(t) req := request.CreatePage{ PublicID: random.String(), @@ -29,14 +26,7 @@ func TestCreatePage(t *testing.T) { } var resp ds.Page - Request(t, RequestArgs{ - method: http.MethodPost, - path: "/pages/", - body: req, - authToken: token, - bindResponse: &resp, - assertStatus: http.StatusCreated, - }) + CREATE(t, "/pages/", req, &resp) contentHTML, err := app.MarkdownToHTML(req.Content) test.CheckErr(t, err) @@ -47,7 +37,7 @@ func TestCreatePage(t *testing.T) { "id": resp.ID, "public_id": req.PublicID, "title": req.Title, - "owner_id": user.ID, + "owner_id": admin.ID, "type": ds.EntityTypePage, "status": ds.EntityStatusApproved, "visibility": ds.EntityVisibilityPublic, @@ -63,7 +53,7 @@ func TestCreatePage(t *testing.T) { // check log created test.AssertInDB(t, tt.DB, "event_logs", test.Data{ "entity_id": resp.ID, - "user_id": user.ID, + "user_id": admin.ID, "type": ds.EventLogEntityAdded, }) @@ -73,7 +63,6 @@ func TestCreatePage(t *testing.T) { method: http.MethodPost, path: "/pages/", body: req, - authToken: token, bindResponse: &errResp, assertStatus: http.StatusUnprocessableEntity, }) @@ -160,8 +149,7 @@ func TestUpdatePage_WithReview(t *testing.T) { } func TestUpdatePage_WithoutReview(t *testing.T) { - user := login(t) - makeAdmin(user) + admin := loginAsAdmin(t) page := create[ds.Page](t) newContent := random.Edit(page.ContentRaw) @@ -192,7 +180,7 @@ func TestUpdatePage_WithoutReview(t *testing.T) { contentPatch := app.MakePatch(page.ContentRaw, newContent) test.AssertInDB(t, tt.DB, "entity_change_requests", test.Data{ - "user_id": user.ID, + "user_id": admin.ID, "entity_id": page.ID, "status": ds.EntityChangeCommitted, "revision": 1, diff --git a/test/api_test/user_test.go b/test/api_test/user_test.go index 70cc0d2..2e2abe4 100644 --- a/test/api_test/user_test.go +++ b/test/api_test/user_test.go @@ -97,43 +97,6 @@ func TestUserSignUp(t *testing.T) { }) } -func TestUserConfirmEmail(t *testing.T) { - ec := create[ds.EmailConfirmation](t) - - req := request.ConfirmEmail{ - Code: ec.Code, - } - - var resp response.Status - Request(t, RequestArgs{ - method: http.MethodPost, - path: "/users/confirm-email", - body: req, - bindResponse: &resp, - assertStatus: http.StatusOK, - }) - - test.AssertInDB(t, tt.DB, "users", test.Data{ - "id": ec.UserID, - "email_confirmed": true, - }) - - test.AssertNotInDB(t, tt.DB, "email_confirmations", test.Data{ - "code": ec.Code, - }) - - test.AssertInDB(t, tt.DB, "event_logs", test.Data{ - "user_id": ec.UserID, - "type": ds.EventLogUserEmailConfirmed, - "is_public": false, - }) - test.AssertInDB(t, tt.DB, "event_logs", test.Data{ - "user_id": ec.UserID, - "type": ds.EventLogUserAccountActivated, - "is_public": true, - }) -} - func TestUserSignIn(t *testing.T) { password := random.String() user := create(t, ds.User{ @@ -159,7 +122,7 @@ func TestChangePassword(t *testing.T) { oldPassword := random.String(10) newPassword := random.String(10) - user := create(t, ds.User{Password: oldPassword}) + user := create(t, ds.User{Password: oldPassword, EmailConfirmed: true}) _, token, err := tt.Service.AuthenticateUser(context.Background(), user.Email, oldPassword) if err != nil { @@ -331,7 +294,7 @@ func TestPasswordReset(t *testing.T) { } func TestChangeEmail(t *testing.T) { - user := create[ds.User](t) + user := create[ds.User](t, ds.User{EmailConfirmed: true}) token := loginAs(t, user) newEmail := random.Email() @@ -407,7 +370,7 @@ func TestChangeEmail(t *testing.T) { func TestChangeUsername(t *testing.T) { password := random.String(10) - user := create(t, ds.User{Password: password}) + user := create(t, ds.User{Password: password, EmailConfirmed: true}) token := loginAs(t, user) newUsername := random.String(10) @@ -483,7 +446,7 @@ func TestChangeUsername(t *testing.T) { func TestDeleteUser(t *testing.T) { password := random.String(10) - user := create(t, ds.User{Password: password}) + user := create(t, ds.User{Password: password, EmailConfirmed: true}) token := loginAs(t, user) diff --git a/test/factory.go b/test/factory.go index 6874e37..dbb8c94 100644 --- a/test/factory.go +++ b/test/factory.go @@ -92,6 +92,7 @@ func createMethodFor[T any]() (reflect.Method, error) { // NOTE: Go does not currently allow methods to declare their own type parameters. // When this becomes possible, move this helper onto *factory.Factory // and remove the wrapper functions. +// TODO: a promising "Proposal: Generic Methods for Go" have landed: https://github.com/golang/go/issues/77273 func Create[T any](t *testing.T, f *factory.Factory, overrideOpt ...T) *T { t.Helper() diff --git a/test/factory/entity.go b/test/factory/entity.go index 8e11d0d..36a1d16 100644 --- a/test/factory/entity.go +++ b/test/factory/entity.go @@ -21,7 +21,7 @@ func (f *Factory) NewEntity(overrideOpt ...ds.Entity) (m *ds.Entity) { status := random.Element(ds.EntityStatuses) if status == ds.EntityStatusApproved { publishedAt = &createdAt - updatedAt = random.ValOrNil(fake.DateRange(createdAt.AddDate(0, -12, -25), createdAt), 50) + updatedAt = random.NilOrValue(fake.DateRange(createdAt.AddDate(0, -12, -25), createdAt), 50) } title := fake.BookTitle() @@ -72,7 +72,7 @@ createEntity: if column, ok := app.IsUniqueViolation(err); ok { switch column { case "public_id": - m.PublicID, err = LookupIUnique(context.Background(), f.db, "entities", "public_id", m.PublicID, func(s string) string { + m.PublicID, err = LookupUnique(context.Background(), f.db, "entities", "public_id", m.PublicID, func(s string) string { return s + "-" + fake.UrlSlug(1) }) if err != nil { diff --git a/test/factory/factory.go b/test/factory/factory.go index d4bebc8..a675605 100644 --- a/test/factory/factory.go +++ b/test/factory/factory.go @@ -109,14 +109,14 @@ func Ten[T any](fn func(m ...T) (*T, error), override ...T) ([]*T, error) { return Batch(10, fn, override...) //nolint:mnd } -// LookupIUnique tries to find a unique value in the given table.column by repeatedly +// LookupUnique tries to find a unique value in the given table.column by repeatedly // querying the database with a transformed version of the input value. // // The transformFn is expected to produce a new candidate value // (for example, by appending a suffix or incrementing a counter) and must eventually // lead to a value that does not exist in the database, otherwise the function will // recurse indefinitely (angry emoji). -func LookupIUnique[T any](ctx context.Context, db *app.DB, table, column string, value T, +func LookupUnique[T any](ctx context.Context, db *app.DB, table, column string, value T, transformFn func(T) T) (T, error) { res := new(T) @@ -138,5 +138,5 @@ func LookupIUnique[T any](ctx context.Context, db *app.DB, table, column string, return value, err } - return LookupIUnique[T](ctx, db, table, column, transformFn(value), transformFn) + return LookupUnique[T](ctx, db, table, column, transformFn(value), transformFn) } diff --git a/test/factory/random/random.go b/test/factory/random/random.go index 987ebfa..5fcbe3a 100644 --- a/test/factory/random/random.go +++ b/test/factory/random/random.go @@ -176,18 +176,18 @@ func Bool() bool { return rand.IntN(2) == 1 //nolint:gosec,mnd } -// ValOrNil returns a pointer to val or nil based on the given probability. +// NilOrValue returns a pointer to val or nil based on the given probability. // The probability is specified in percent (0–100) and defaults to 50% if omitted. // // Examples: // -// ValOrNil("hello") // ~50% chance to return &"hello" -// ValOrNil("hello", 10) // 10% chance to return &"hello" -// ValOrNil("hello", 100) // always returns &"hello" -// ValOrNil("hello", 0) // always returns nil +// NilOrValue("hello") // ~50% chance to return &"hello" +// NilOrValue("hello", 10) // 10% chance to return &"hello" +// NilOrValue("hello", 100) // always returns &"hello" +// NilOrValue("hello", 0) // always returns nil // //nolint:mnd -func ValOrNil[T any](val T, probabilityOpt ...int) *T { +func NilOrValue[T any](val T, probabilityOpt ...int) *T { probability := 50 if len(probabilityOpt) == 1 { probability = probabilityOpt[0] @@ -211,7 +211,7 @@ func ValOrNil[T any](val T, probabilityOpt ...int) *T { // based on the given probability. func Maybe[T any](val T, probabilityOpt ...int) T { var v T - if ValOrNil(v, probabilityOpt...) != nil { + if NilOrValue(v, probabilityOpt...) != nil { return val } diff --git a/test/factory/user.go b/test/factory/user.go index 81e76d5..1e16762 100644 --- a/test/factory/user.go +++ b/test/factory/user.go @@ -10,7 +10,7 @@ import ( "golang.org/x/crypto/bcrypt" ) -// NewUser ... +// NewUser builds a new User struct with random data for testing purposes. func (f *Factory) NewUser(overrideOpt ...ds.User) (m *ds.User) { m = &ds.User{ ID: ds.NewID(), @@ -21,6 +21,7 @@ func (f *Factory) NewUser(overrideOpt ...ds.User) (m *ds.User) { CreatedAt: time.Now(), UpdatedAt: nil, DeletedAt: nil, + CleanedAt: nil, } if len(overrideOpt) == 1 { @@ -30,7 +31,7 @@ func (f *Factory) NewUser(overrideOpt ...ds.User) (m *ds.User) { return } -// CreateUser ... +// CreateUser creates and persists a user to the database for testing purposes. func (f *Factory) CreateUser(overrideOpt ...ds.User) (m *ds.User, err error) { m = f.NewUser(overrideOpt...) diff --git a/test/seed/books.go b/test/seed/books.go index 0e04703..f30c188 100644 --- a/test/seed/books.go +++ b/test/seed/books.go @@ -36,7 +36,7 @@ func (s *Seed) Books(ctx context.Context, count int) (err error) { } uniqueSlug := func(from string) (string, error) { - return factory.LookupIUnique(ctx, s.db, "entities", "public_id", from, func(s string) string { + return factory.LookupUnique(ctx, s.db, "entities", "public_id", from, func(s string) string { return s + "-" + fake.UrlSlug(1) }) } @@ -83,7 +83,7 @@ func (s *Seed) Books(ctx context.Context, count int) (err error) { PublicID: app.Slug(title), OwnerID: ownerID, PreviewFileID: cover.ID, - DeletedAt: random.ValOrNil(fake.DateRange(time.Now().AddDate(0, -12, -25), time.Now()), 10), + DeletedAt: random.NilOrValue(fake.DateRange(time.Now().AddDate(0, -12, -25), time.Now()), 10), }) createBook: @@ -118,11 +118,11 @@ func (s *Seed) Books(ctx context.Context, count int) (err error) { // change requests if random.Bool() { maybeData := map[string]any{ - "title": random.ValOrNil(random.Patch(book.Title)), - "summary": random.ValOrNil(random.Patch(book.Summary)), - "description": random.ValOrNil(random.Patch(book.Description)), - "homepage": random.ValOrNil(random.Patch(book.Homepage)), - "release_date": random.ValOrNil(app.MakePatch(book.ReleaseDate, random.ReleaseDate())), + "title": random.NilOrValue(random.Patch(book.Title)), + "summary": random.NilOrValue(random.Patch(book.Summary)), + "description": random.NilOrValue(random.Patch(book.Description)), + "homepage": random.NilOrValue(random.Patch(book.Homepage)), + "release_date": random.NilOrValue(app.MakePatch(book.ReleaseDate, random.ReleaseDate())), } data := make(map[string]any) diff --git a/test/seed/seed.go b/test/seed/seed.go index 66f5c20..0faa221 100644 --- a/test/seed/seed.go +++ b/test/seed/seed.go @@ -140,7 +140,7 @@ func (s *Seed) All(ctx context.Context, count int) (err error) { // RandomUserID returns a random user ID. // -// On the first call, it loads up to 100 user IDs from the database and caches +// On the first call, it loads up to 100 random user IDs from the database and caches // them for subsequent calls to avoid repeated queries. func (s *Seed) RandomUserID(ctx context.Context) (ds.ID, error) { const q = `SELECT id FROM users ORDER BY RANDOM() LIMIT 100` diff --git a/test/seed/users.go b/test/seed/users.go index 2813792..ff23354 100644 --- a/test/seed/users.go +++ b/test/seed/users.go @@ -25,13 +25,13 @@ func (s *Seed) Users(ctx context.Context, count int) (err error) { } uniqueUsername := func(from string) (string, error) { - return factory.LookupIUnique(ctx, s.db, "users", "username", from, func(s string) string { + return factory.LookupUnique(ctx, s.db, "users", "username", from, func(s string) string { return s + "." + random.String(5) }) } uniqueEmail := func(from string) (string, error) { - return factory.LookupIUnique(ctx, s.db, "users", "email", from, func(s string) string { + return factory.LookupUnique(ctx, s.db, "users", "email", from, func(s string) string { return random.String(5) + "." + s }) } @@ -41,8 +41,10 @@ func (s *Seed) Users(ctx context.Context, count int) (err error) { for range count { eg.Go(func() error { createdAt := fake.DateRange(time.Now().AddDate(0, -12, 0), time.Now()) - updatedAt := random.ValOrNil(fake.DateRange(createdAt.AddDate(0, -12, -25), createdAt), 75) - deletedAt := random.ValOrNil(fake.DateRange(createdAt.AddDate(0, -12, -25), createdAt), 75) + // nil or after createdAt + updatedAt := random.NilOrValue(fake.DateRange(createdAt.AddDate(0, -12, -25), createdAt), 75) + // nil or after createdAt + deletedAt := random.NilOrValue(fake.DateRange(createdAt.AddDate(0, -12, -25), createdAt), 75) u := ds.User{ Username: fake.Username(), diff --git a/test/service_test/hard_delete_user_test.go b/test/service_test/hard_delete_user_test.go index 578e401..83839ce 100644 --- a/test/service_test/hard_delete_user_test.go +++ b/test/service_test/hard_delete_user_test.go @@ -2,15 +2,18 @@ package service_test import ( "context" + "strings" "testing" "github.com/gopl-dev/server/app/ds" "github.com/gopl-dev/server/test" "github.com/gopl-dev/server/test/factory" + "github.com/stretchr/testify/assert" ) -func TestHardDeleteUser(t *testing.T) { +func TestCleanupDeletedUser(t *testing.T) { user := create[ds.User](t) + ctx := context.Background() // user sessions _, err := factory.Five(tt.Factory.CreateUserSession, ds.UserSession{UserID: user.ID}) @@ -28,12 +31,18 @@ func TestHardDeleteUser(t *testing.T) { _, err = factory.Five(tt.Factory.CreateChangeEmailRequest, ds.ChangeEmailRequest{UserID: user.ID}) test.CheckErr(t, err) - err = tt.Service.HardDeleteUser(context.Background(), user.ID) + err = tt.Service.CleanupDeletedUser(ctx, user.ID) test.CheckErr(t, err) test.AssertNotInDB(t, tt.DB, "user_sessions", test.Data{"user_id": user.ID}) test.AssertNotInDB(t, tt.DB, "email_confirmations", test.Data{"user_id": user.ID}) test.AssertNotInDB(t, tt.DB, "password_reset_tokens", test.Data{"user_id": user.ID}) test.AssertNotInDB(t, tt.DB, "change_email_requests", test.Data{"user_id": user.ID}) - test.AssertNotInDB(t, tt.DB, "users", test.Data{"id": user.ID}) + + user, err = tt.Service.GetUserByID(ctx, user.ID) + test.CheckErr(t, err) + + assert.True(t, strings.HasPrefix(user.Username, ds.DeletedUsername)) + assert.True(t, strings.HasPrefix(user.Email, ds.DeletedUsername)) + assert.True(t, strings.HasPrefix(user.Password, ds.DeletedUsername)) } diff --git a/test/worker_test/cleanup_change_email_requests_test.go b/test/worker_test/cleanup_change_email_requests_test.go index 13bbb60..7f9fb3a 100644 --- a/test/worker_test/cleanup_change_email_requests_test.go +++ b/test/worker_test/cleanup_change_email_requests_test.go @@ -11,23 +11,29 @@ import ( ) func TestCleanupChangeEmailRequests(t *testing.T) { + // create user user := create[ds.User](t) + // create 10 "email change requests" that is expired _, err := factory.Ten(tt.Factory.CreateChangeEmailRequest, ds.ChangeEmailRequest{ UserID: user.ID, ExpiresAt: time.Now().Add(-time.Hour), }) test.CheckErr(t, err) + // run worker's job runJob(t, cleanupchangeemailrequests.NewJob()) + // check that job did what expected test.AssertNotInDB(t, tt.DB, "change_email_requests", test.Data{ "user_id": user.ID, }) + // do it again, against false-positive case + // 1. create "email change requests" that is fresh + // 2. run job + // 3. "email change requests" should not be removed (yet) req := create[ds.ChangeEmailRequest](t) - runJob(t, cleanupchangeemailrequests.Job{}) - test.AssertInDB(t, tt.DB, "change_email_requests", test.Data{"id": req.ID}) } diff --git a/test/worker_test/cleanup_deleted_user_accounts_test.go b/test/worker_test/cleanup_deleted_user_accounts_test.go index 7bf4019..d22987f 100644 --- a/test/worker_test/cleanup_deleted_user_accounts_test.go +++ b/test/worker_test/cleanup_deleted_user_accounts_test.go @@ -33,5 +33,8 @@ func TestCleanupDeletedUserAccounts(t *testing.T) { test.AssertNotInDB(t, tt.DB, "password_reset_tokens", test.Data{"user_id": user.ID}) test.AssertNotInDB(t, tt.DB, "email_confirmations", test.Data{"user_id": user.ID}) test.AssertNotInDB(t, tt.DB, "change_email_requests", test.Data{"user_id": user.ID}) - test.AssertNotInDB(t, tt.DB, "users", test.Data{"id": user.ID}) + test.AssertInDB(t, tt.DB, "users", test.Data{ + "id": user.ID, + "cleaned_at": test.NotNull, + }) } diff --git a/worker/cleanup_deleted_users/worker.go b/worker/cleanup_deleted_users/worker.go index 6370d6e..6aff87c 100644 --- a/worker/cleanup_deleted_users/worker.go +++ b/worker/cleanup_deleted_users/worker.go @@ -33,22 +33,22 @@ func (w Job) Schedule() gocron.JobDefinition { ) } -// Do executes the job's task, which is to permanently delete users who have been +// Do executes the job's task, which is cleanup users data who have been // soft-deleted for more than a certain period. func (w Job) Do(ctx context.Context, s *service.Service, _ *app.DB) (err error) { users, _, err := s.FilterUsers(ctx, ds.UsersFilter{ - DeletedAt: ds.DtBefore(time.Now().Add(-ds.CleanupDeletedUserAfter)), - WithCount: true, + DeletedAt: ds.DtBefore(time.Now().Add(-ds.CleanupDeletedUserAfter)), + NotCleaned: true, + PerPage: ds.PerPageNoLimit, }) if err != nil { return } var eg errgroup.Group - for _, user := range users { eg.Go(func() error { - return s.HardDeleteUser(ctx, user.ID) + return s.CleanupDeletedUser(ctx, user.ID) }) }