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 != "" { +