From 660f65ba95c90d09d85761a6d4dec586181a744d Mon Sep 17 00:00:00 2001 From: Asaf Gartner Date: Sun, 8 Aug 2021 23:05:52 +0300 Subject: [PATCH] Registration flow and email --- public/style.css | 4 + public/themes/dark/theme.css | 1 + public/themes/light/theme.css | 1 + src/admintools/admintools.go | 70 ++- src/auth/auth.go | 48 ++ src/auth/session.go | 1 + src/config/config.go.example | 8 + src/config/types.go | 10 + src/email/email.go | 121 ++++ src/hmnurl/hmnurl_test.go | 27 +- src/hmnurl/urls.go | 43 +- src/migration/migration.go | 4 +- src/migration/migrationTemplate.txt | 1 + ...08-08T093624Z_FixupUsersForRegistration.go | 79 +++ .../2021-08-08T110926Z_AddDeletionIndices.go | 61 ++ .../2021-08-08T134655Z_DeleteInactiveUsers.go | 119 ++++ ...-08T141819Z_FinalizeOneTimeTokenChanges.go | 60 ++ src/models/onetimetoken.go | 27 + src/models/user.go | 22 +- src/perf/perf.go | 15 + src/rawdata/scss/_notices.scss | 4 + src/rawdata/scss/themes/dark/_variables.scss | 1 + src/rawdata/scss/themes/light/_variables.scss | 1 + src/templates/mapping.go | 6 +- src/templates/src/auth_email_validation.html | 19 + .../src/auth_email_validation_success.html | 13 + src/templates/src/auth_login.html | 25 + src/templates/src/auth_register.html | 55 ++ src/templates/src/auth_register_success.html | 11 + src/templates/src/email_registration.html | 13 + src/templates/src/layouts/base.html | 1 + src/templates/src/podcast_edit.html | 1 - src/templates/src/podcast_episode_edit.html | 1 - src/templates/src/project_homepage.html | 1 - src/templates/types.go | 15 +- src/website/landing.go | 4 + src/website/login.go | 556 ++++++++++++++++-- src/website/podcast.go | 6 +- src/website/projects.go | 5 +- src/website/requesthandling.go | 32 + src/website/routes.go | 19 +- src/website/website.go | 1 + 42 files changed, 1427 insertions(+), 85 deletions(-) create mode 100644 src/email/email.go create mode 100644 src/migration/migrations/2021-08-08T093624Z_FixupUsersForRegistration.go create mode 100644 src/migration/migrations/2021-08-08T110926Z_AddDeletionIndices.go create mode 100644 src/migration/migrations/2021-08-08T134655Z_DeleteInactiveUsers.go create mode 100644 src/migration/migrations/2021-08-08T141819Z_FinalizeOneTimeTokenChanges.go create mode 100644 src/models/onetimetoken.go create mode 100644 src/templates/src/auth_email_validation.html create mode 100644 src/templates/src/auth_email_validation_success.html create mode 100644 src/templates/src/auth_login.html create mode 100644 src/templates/src/auth_register.html create mode 100644 src/templates/src/auth_register_success.html create mode 100644 src/templates/src/email_registration.html diff --git a/public/style.css b/public/style.css index d1fa4b9..8f1fde0 100644 --- a/public/style.css +++ b/public/style.css @@ -9520,3 +9520,7 @@ span.icon-rss::before { .notice-success { background-color: #43a52f; background-color: var(--notice-success-color); } + +.notice-failure { + background-color: #b42222; + background-color: var(--notice-failure-color); } diff --git a/public/themes/dark/theme.css b/public/themes/dark/theme.css index f94cc9e..190a1dc 100644 --- a/public/themes/dark/theme.css +++ b/public/themes/dark/theme.css @@ -248,6 +248,7 @@ will throw an error. --notice-lts-color: #2a681d; --notice-lts-reqd-color: #876327; --notice-success-color: #2a681d; + --notice-failure-color: #7a2020; --optionbar-border-color: #333; --tab-background: #181818; --tab-border-color: #3f3f3f; diff --git a/public/themes/light/theme.css b/public/themes/light/theme.css index 846c094..d506bde 100644 --- a/public/themes/light/theme.css +++ b/public/themes/light/theme.css @@ -266,6 +266,7 @@ will throw an error. --notice-lts-color: #43a52f; --notice-lts-reqd-color: #aa7d30; --notice-success-color: #43a52f; + --notice-failure-color: #b42222; --optionbar-border-color: #ccc; --tab-background: #fff; --tab-border-color: #d8d8d8; diff --git a/src/admintools/admintools.go b/src/admintools/admintools.go index 8c6617d..a414e6e 100644 --- a/src/admintools/admintools.go +++ b/src/admintools/admintools.go @@ -8,6 +8,12 @@ import ( "git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/email" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/perf" + "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/website" "github.com/jackc/pgx/v4" "github.com/spf13/cobra" @@ -57,6 +63,68 @@ func init() { fmt.Printf("Successfully updated password for '%s'\n", canonicalUsername) }, } - website.WebsiteCommand.AddCommand(setPasswordCommand) + + activateUserCommand := &cobra.Command{ + Use: "activateuser [username]", + Short: "Activates a user manually", + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 1 { + fmt.Printf("You must provide a username.\n\n") + cmd.Usage() + os.Exit(1) + } + + username := args[0] + + ctx := context.Background() + conn := db.NewConnPool(1, 1) + defer conn.Close() + + res, err := conn.Exec(ctx, "UPDATE auth_user SET status = $1 WHERE LOWER(username) = LOWER($2);", models.UserStatusActive, username) + if err != nil { + panic(err) + } + if res.RowsAffected() == 0 { + fmt.Printf("User not found.\n\n") + } + + fmt.Printf("User has been successfully activated.\n\n") + }, + } + website.WebsiteCommand.AddCommand(activateUserCommand) + + sendTestMailCommand := &cobra.Command{ + Use: "sendtestmail [type] [toAddress] [toName]", + Short: "Sends a test mail", + Run: func(cmd *cobra.Command, args []string) { + templates.Init() + if len(args) < 3 { + fmt.Printf("You must provide the email type and recipient details.\n\n") + cmd.Usage() + os.Exit(1) + } + + emailType := args[0] + toAddress := args[1] + toName := args[2] + + p := perf.MakeNewRequestPerf("admintools", "email test", emailType) + var err error + switch emailType { + case "registration": + err = email.SendRegistrationEmail(toAddress, toName, "test_user", "test_token", p) + default: + fmt.Printf("You must provide a valid email type\n\n") + cmd.Usage() + os.Exit(1) + } + p.EndRequest() + perf.LogPerf(p, logging.Info()) + if err != nil { + panic(oops.New(err, "Failed to send test email")) + } + }, + } + website.WebsiteCommand.AddCommand(sendTestMailCommand) } diff --git a/src/auth/auth.go b/src/auth/auth.go index 6806c83..7c5371f 100644 --- a/src/auth/auth.go +++ b/src/auth/auth.go @@ -11,8 +11,12 @@ import ( "io" "strconv" "strings" + "time" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4/pgxpool" "golang.org/x/crypto/argon2" "golang.org/x/crypto/pbkdf2" @@ -187,3 +191,47 @@ func UpdatePassword(ctx context.Context, conn *pgxpool.Pool, username string, hp return nil } + +func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) { + tag, err := conn.Exec(ctx, + ` + DELETE FROM auth_user + WHERE + status = $1 AND + (SELECT COUNT(*) as ct FROM handmade_onetimetoken AS ott WHERE ott.owner_id = auth_user.id AND ott.expires < $2) > 0; + `, + models.UserStatusInactive, + time.Now(), + ) + + if err != nil { + return 0, oops.New(err, "failed to delete inactive users") + } + + return tag.RowsAffected(), nil +} + +func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + + t := time.NewTicker(10 * time.Second) + for { + select { + case <-t.C: + n, err := DeleteInactiveUsers(ctx, conn) + if err == nil { + if n > 0 { + logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users") + } + } else { + logging.Error().Err(err).Msg("Failed to delete expired sessions") + } + case <-ctx.Done(): + return + } + } + }() + return done +} diff --git a/src/auth/session.go b/src/auth/session.go index 0d6065c..0e7e537 100644 --- a/src/auth/session.go +++ b/src/auth/session.go @@ -94,6 +94,7 @@ func NewSessionCookie(session *models.Session) *http.Cookie { Value: session.ID, Domain: config.Config.Auth.CookieDomain, + Path: "/", Expires: time.Now().Add(sessionDuration), Secure: config.Config.Auth.CookieSecure, diff --git a/src/config/config.go.example b/src/config/config.go.example index ec2a376..a9dc506 100644 --- a/src/config/config.go.example +++ b/src/config/config.go.example @@ -25,6 +25,14 @@ var Config = HMNConfig{ CookieDomain: ".handmade.local", CookieSecure: false, }, + Email: EmailConfig{ + ServerAddress: "smtp.example.com", + ServerPort: 587, + FromAddress: "noreply@example.com", + FromAddressPassword: "", + FromName: "Handmade Network Team", + OverrideRecipientEmail: "override@handmadedev.org", // NOTE(asaf): If this is not empty, all emails will be redirected to this address. + }, DigitalOcean: DigitalOceanConfig{ AssetsSpacesKey: "", AssetsSpacesSecret: "", diff --git a/src/config/types.go b/src/config/types.go index 4b8dee0..f76a88b 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -23,6 +23,7 @@ type HMNConfig struct { LogLevel zerolog.Level Postgres PostgresConfig Auth AuthConfig + Email EmailConfig DigitalOcean DigitalOceanConfig } @@ -52,6 +53,15 @@ type DigitalOceanConfig struct { AssetsPublicUrlRoot string } +type EmailConfig struct { + ServerAddress string + ServerPort int + FromAddress string + FromAddressPassword string + FromName string + OverrideRecipientEmail string +} + func (info PostgresConfig) DSN() string { return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName) } diff --git a/src/email/email.go b/src/email/email.go new file mode 100644 index 0000000..d1f361c --- /dev/null +++ b/src/email/email.go @@ -0,0 +1,121 @@ +package email + +import ( + "bytes" + "fmt" + "mime" + "mime/quotedprintable" + "net/smtp" + "regexp" + "strings" + "time" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/perf" + "git.handmade.network/hmn/hmn/src/templates" +) + +type RegistrationEmailData struct { + Name string + HomepageUrl string + CompleteRegistrationUrl string +} + +func SendRegistrationEmail(toAddress string, toName string, username string, completionToken string, perf *perf.RequestPerf) error { + perf.StartBlock("EMAIL", "Registration email") + + perf.StartBlock("EMAIL", "Rendering template") + contents, err := renderTemplate("email_registration.html", RegistrationEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + CompleteRegistrationUrl: hmnurl.BuildEmailConfirmation(username, completionToken), + }) + if err != nil { + return err + } + perf.EndBlock() + + perf.StartBlock("EMAIL", "Sending email") + err = sendMail(toAddress, toName, "[handmade.network] Registration confirmation", contents) + if err != nil { + return oops.New(err, "Failed to send email") + } + perf.EndBlock() + + perf.EndBlock() + + return nil +} + +var EmailRegex = regexp.MustCompile(`^[^:\p{Cc} ]+@[^:\p{Cc} ]+\.[^:\p{Cc} ]+$`) + +func IsEmail(address string) bool { + return EmailRegex.Match([]byte(address)) +} + +func renderTemplate(name string, data interface{}) (string, error) { + var buffer bytes.Buffer + template, hasTemplate := templates.Templates[name] + if !hasTemplate { + return "", oops.New(nil, "Template not found: %s", name) + } + err := template.Execute(&buffer, data) + if err != nil { + return "", oops.New(err, "Failed to render template for email") + } + contentString := string(buffer.Bytes()) + contentString = strings.ReplaceAll(contentString, "\n", "\r\n") + return contentString, nil +} + +func sendMail(toAddress, toName, subject, contentHtml string) error { + if config.Config.Email.OverrideRecipientEmail != "" { + toAddress = config.Config.Email.OverrideRecipientEmail + } + contents := prepMailContents( + makeHeaderAddress(toAddress, toName), + makeHeaderAddress(config.Config.Email.FromAddress, config.Config.Email.FromName), + subject, + contentHtml, + ) + return smtp.SendMail( + fmt.Sprintf("%s:%d", config.Config.Email.ServerAddress, config.Config.Email.ServerPort), + smtp.PlainAuth("", config.Config.Email.FromAddress, config.Config.Email.FromAddressPassword, config.Config.Email.ServerAddress), + config.Config.Email.FromAddress, + []string{toAddress}, + contents, + ) +} + +func makeHeaderAddress(email, fullname string) string { + if fullname != "" { + encoded := mime.BEncoding.Encode("utf-8", fullname) + if encoded == fullname { + encoded = strings.ReplaceAll(encoded, `"`, `\"`) + encoded = fmt.Sprintf("\"%s\"", encoded) + } + return fmt.Sprintf("%s <%s>", encoded, email) + } else { + return email + } +} + +func prepMailContents(toLine string, fromLine string, subject string, contentHtml string) []byte { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("To: %s\r\n", toLine)) + builder.WriteString(fmt.Sprintf("From: %s\r\n", fromLine)) + builder.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))) + builder.WriteString(fmt.Sprintf("Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))) + builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + builder.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + builder.WriteString("\r\n") + writer := quotedprintable.NewWriter(&builder) + writer.Write([]byte(contentHtml)) + writer.Close() + builder.WriteString("\r\n") + + return []byte(builder.String()) +} diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 99f8e46..8076853 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -28,6 +28,7 @@ func TestUrl(t *testing.T) { func TestHomepage(t *testing.T) { AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil) + AssertRegexMatch(t, BuildHomepageWithRegistrationSuccess(), RegexHomepage, nil) AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil) AssertSubdomain(t, BuildProjectHomepage("hero"), "hero") } @@ -70,6 +71,14 @@ func TestRegister(t *testing.T) { AssertRegexMatch(t, BuildRegister(), RegexRegister, nil) } +func TestRegistrationSuccess(t *testing.T) { + AssertRegexMatch(t, BuildRegistrationSuccess(), RegexRegistrationSuccess, nil) +} + +func TestEmailConfirmation(t *testing.T) { + AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token"), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"}) +} + func TestStaticPages(t *testing.T) { AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil) AssertRegexMatch(t, BuildAbout(), RegexAbout, nil) @@ -204,12 +213,12 @@ func TestBlog(t *testing.T) { } func TestBlogThread(t *testing.T) { - AssertRegexMatch(t, BuildBlogThread("", 1, "", 1), RegexBlogThread, map[string]string{"threadid": "1"}) - AssertRegexMatch(t, BuildBlogThread("", 1, "", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"}) - AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"}) - AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 2, 123), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"}) - AssertRegexNoMatch(t, BuildBlogThread("", 1, "", 2), RegexBlog) - AssertSubdomain(t, BuildBlogThread("hero", 1, "", 1), "hero") + AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"}) + AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"}) + AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://"), RegexBlogThread, map[string]string{"threadid": "1"}) + AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 123), RegexBlogThread, map[string]string{"threadid": "1"}) + AssertRegexNoMatch(t, BuildBlogThread("", 1, ""), RegexBlog) + AssertSubdomain(t, BuildBlogThread("hero", 1, ""), "hero") } func TestBlogPost(t *testing.T) { @@ -236,12 +245,6 @@ func TestBlogPostReply(t *testing.T) { AssertSubdomain(t, BuildBlogPostReply("hero", 1, 2), "hero") } -func TestBlogPostQuote(t *testing.T) { - AssertRegexMatch(t, BuildBlogPostQuote("", 1, 2), RegexBlogPostQuote, map[string]string{"threadid": "1", "postid": "2"}) - AssertRegexNoMatch(t, BuildBlogPostQuote("", 1, 2), RegexBlogPost) - AssertSubdomain(t, BuildBlogPostQuote("hero", 1, 2), "hero") -} - func TestLibrary(t *testing.T) { AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil) AssertSubdomain(t, BuildLibrary("hero"), "hero") diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 0472719..8d777f2 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -16,12 +16,17 @@ Any function in this package whose name starts with Build is required to be cove This helps ensure that we don't generate URLs that can't be routed. */ +var RegexOldHome = regexp.MustCompile("^/home$") var RegexHomepage = regexp.MustCompile("^/$") func BuildHomepage() string { return Url("/", nil) } +func BuildHomepageWithRegistrationSuccess() string { + return Url("/", []Q{{Name: "registered", Value: "true"}}) +} + func BuildProjectHomepage(projectSlug string) string { defer CatchPanic() return ProjectUrl("/", nil, projectSlug) @@ -57,11 +62,11 @@ func BuildLoginAction(redirectTo string) string { return Url("/login", []Q{{Name: "redirect", Value: redirectTo}}) } -var RegexLoginPage = regexp.MustCompile("^/_login$") +var RegexLoginPage = regexp.MustCompile("^/login$") func BuildLoginPage(redirectTo string) string { defer CatchPanic() - return Url("/_login", []Q{{Name: "redirect", Value: redirectTo}}) + return Url("/login", []Q{{Name: "redirect", Value: redirectTo}}) } var RegexLogoutAction = regexp.MustCompile("^/logout$") @@ -74,11 +79,34 @@ func BuildLogoutAction(redir string) string { return Url("/logout", []Q{{"redirect", redir}}) } -var RegexRegister = regexp.MustCompile("^/_register$") +var RegexRegister = regexp.MustCompile("^/register$") func BuildRegister() string { defer CatchPanic() - return Url("/_register", nil) + return Url("/register", nil) +} + +var RegexRegistrationSuccess = regexp.MustCompile("^/registered_successfully$") + +func BuildRegistrationSuccess() string { + defer CatchPanic() + return Url("/registered_successfully", nil) +} + +// TODO(asaf): Delete the old version a bit after launch +var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P[\w\ \.\,\-@\+\_]+)/(?P[\d\w]+)/(?P.+)[\/]?$`) +var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P[^/]+)/(?P[^/]+)$") + +func BuildEmailConfirmation(username, token string) string { + defer CatchPanic() + return Url(fmt.Sprintf("/email_confirmation/%s/%s", url.PathEscape(username), token), nil) +} + +var RegexPasswordResetRequest = regexp.MustCompile("^/password_reset$") + +func BuildPasswordResetRequest() string { + defer CatchPanic() + return Url("/password_reset", nil) } /* @@ -145,7 +173,12 @@ func BuildUserProfile(username string) string { if len(username) == 0 { panic(oops.New(nil, "Username must not be blank")) } - return Url("/m/"+username, nil) + return Url("/m/"+url.PathEscape(username), nil) +} + +// TODO +func BuildUserSettings(username string) string { + return "" } /* diff --git a/src/migration/migration.go b/src/migration/migration.go index 82a1410..a8a85ba 100644 --- a/src/migration/migration.go +++ b/src/migration/migration.go @@ -352,7 +352,7 @@ func SeedFromFile(seedFile string, afterMigration types.MigrationVersion) { Migrate(afterMigration) fmt.Println("Executing seed...") - cmd := exec.Command("psql", + cmd := exec.Command("pg_restore", "--single-transaction", "--dbname", config.Config.Postgres.DbName, @@ -361,7 +361,7 @@ func SeedFromFile(seedFile string, afterMigration types.MigrationVersion) { "--username", config.Config.Postgres.User, "--password", - "-f", + "--data-only", seedFile, ) fmt.Println("Running command:", cmd) diff --git a/src/migration/migrationTemplate.txt b/src/migration/migrationTemplate.txt index 53b4e35..7656e2f 100644 --- a/src/migration/migrationTemplate.txt +++ b/src/migration/migrationTemplate.txt @@ -5,6 +5,7 @@ import ( "time" "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" "github.com/jackc/pgx/v4" ) diff --git a/src/migration/migrations/2021-08-08T093624Z_FixupUsersForRegistration.go b/src/migration/migrations/2021-08-08T093624Z_FixupUsersForRegistration.go new file mode 100644 index 0000000..a4f9bf8 --- /dev/null +++ b/src/migration/migrations/2021-08-08T093624Z_FixupUsersForRegistration.go @@ -0,0 +1,79 @@ +package migrations + +import ( + "context" + "fmt" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(FixupUsersForRegistration{}) +} + +type FixupUsersForRegistration struct{} + +func (m FixupUsersForRegistration) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 8, 8, 9, 36, 24, 0, time.UTC)) +} + +func (m FixupUsersForRegistration) Name() string { + return "FixupUsersForRegistration" +} + +func (m FixupUsersForRegistration) Description() string { + return "Remove PendingUser and add the necessary fields to users" +} + +func (m FixupUsersForRegistration) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE auth_user + ADD status INT NOT NULL DEFAULT 1; + ALTER TABLE auth_user + ADD registration_ip INET; + ALTER TABLE auth_user + ALTER COLUMN is_staff SET DEFAULT FALSE; + ALTER TABLE auth_user + ALTER COLUMN timezone SET DEFAULT 'UTC'; + ALTER TABLE auth_user + DROP first_name; + ALTER TABLE auth_user + DROP last_name; + ALTER TABLE auth_user + DROP color_1; + ALTER TABLE auth_user + DROP color_2; + `) + if err != nil { + return oops.New(err, "failed to modify auth_user") + } + + fmt.Printf("Setting status on users.\n") + // status = INACTIVE(1) when !is_active && last_login is null + // ACTIVE(2) when is_active + // BANNED(3) when !is_active && last_login is not null + _, err = tx.Exec(ctx, ` + UPDATE auth_user + SET status = CASE is_active WHEN TRUE THEN 2 ELSE (CASE WHEN last_login IS NULL THEN 1 ELSE 3 END) END; + `) + if err != nil { + return oops.New(err, "failed to set user status") + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE auth_user + DROP is_active; + `) + if err != nil { + return oops.New(err, "failed to drop is_active") + } + + return nil +} + +func (m FixupUsersForRegistration) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/migration/migrations/2021-08-08T110926Z_AddDeletionIndices.go b/src/migration/migrations/2021-08-08T110926Z_AddDeletionIndices.go new file mode 100644 index 0000000..5a54fd7 --- /dev/null +++ b/src/migration/migrations/2021-08-08T110926Z_AddDeletionIndices.go @@ -0,0 +1,61 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(AddDeletionIndices{}) +} + +type AddDeletionIndices struct{} + +func (m AddDeletionIndices) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 8, 8, 11, 9, 26, 0, time.UTC)) +} + +func (m AddDeletionIndices) Name() string { + return "AddDeletionIndices" +} + +func (m AddDeletionIndices) Description() string { + return "Add indices to tables that depend on auth_user to allow for faster deletion of users" +} + +func (m AddDeletionIndices) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, `DROP TABLE auth_user_user_permissions;`) + if err != nil { + return oops.New(err, "failed to drop auth_user_user_permissions") + } + + _, err = tx.Exec(ctx, `DROP TABLE django_admin_log;`) + if err != nil { + return oops.New(err, "failed to drop django_admin_log") + } + + _, err = tx.Exec(ctx, ` + CREATE INDEX handmade_communicationchoice_userid ON handmade_communicationchoice (user_id); + CREATE INDEX handmade_communicationsubcategory_userid ON handmade_communicationsubcategory (user_id); + CREATE INDEX handmade_communicationsubthread_userid ON handmade_communicationsubthread (user_id); + CREATE INDEX handmade_discord_hmnuserid ON handmade_discord (hmn_user_id); + CREATE INDEX handmade_links_userid ON handmade_links (user_id); + CREATE INDEX handmade_passwordresetrequest_userid ON handmade_passwordresetrequest (user_id); + CREATE INDEX handmade_post_authorid ON handmade_post (author_id); + CREATE INDEX handmade_postversion_editorid ON handmade_postversion (editor_id); + CREATE INDEX handmade_thread_personalarticleuserid ON handmade_thread (personal_article_user_id); + CREATE INDEX handmade_user_projects_userid ON handmade_user_projects (user_id); + `) + if err != nil { + return oops.New(err, "failed to create user_id indices") + } + return nil +} + +func (m AddDeletionIndices) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/migration/migrations/2021-08-08T134655Z_DeleteInactiveUsers.go b/src/migration/migrations/2021-08-08T134655Z_DeleteInactiveUsers.go new file mode 100644 index 0000000..eeec40c --- /dev/null +++ b/src/migration/migrations/2021-08-08T134655Z_DeleteInactiveUsers.go @@ -0,0 +1,119 @@ +package migrations + +import ( + "context" + "fmt" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(DeleteInactiveUsers{}) +} + +type DeleteInactiveUsers struct{} + +func (m DeleteInactiveUsers) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 8, 8, 13, 46, 55, 0, time.UTC)) +} + +func (m DeleteInactiveUsers) Name() string { + return "DeleteInactiveUsers" +} + +func (m DeleteInactiveUsers) Description() string { + return "Delete inactive users and expired onetimetokens" +} + +func (m DeleteInactiveUsers) Up(ctx context.Context, tx pgx.Tx) error { + var err error + res, err := tx.Exec(ctx, + ` + DELETE FROM handmade_passwordresetrequest + USING handmade_onetimetoken + WHERE handmade_onetimetoken.expires < $1 AND handmade_passwordresetrequest.confirmation_token_id = handmade_onetimetoken.id; + `, + time.Now(), + ) + if err != nil { + return oops.New(err, "failed to delete password reset requests") + } + fmt.Printf("Deleted %v expired password reset requests.\n", res.RowsAffected()) + + fmt.Printf("Deleting inactive users. This might take a minute.\n") + res, err = tx.Exec(ctx, + ` + DELETE FROM auth_user + WHERE status = 1 AND date_joined < $1; + `, + time.Now().Add(-(time.Hour * 24 * 7)), + ) + if err != nil { + return oops.New(err, "failed to delete inactive users") + } + fmt.Printf("Deleted %v inactive users.\n", res.RowsAffected()) + + _, err = tx.Exec(ctx, ` + ALTER TABLE handmade_onetimetoken + DROP used; + ALTER TABLE handmade_onetimetoken + ADD owner_id INT REFERENCES auth_user(id) ON DELETE CASCADE; + + ALTER TABLE handmade_userpending + DROP CONSTRAINT handma_activation_token_id_0b4a4b06_fk_handmade_onetimetoken_id; + `) + + _, err = tx.Exec(ctx, ` + UPDATE handmade_userpending + SET activation_token_id = NULL + WHERE (SELECT count(*) AS ct FROM handmade_onetimetoken WHERE id = activation_token_id) = 0; + `) + + res, err = tx.Exec(ctx, + ` + DELETE FROM handmade_onetimetoken + WHERE expires < $1 + `, + time.Now(), + ) + if err != nil { + return oops.New(err, "failed to delete expired tokens") + } + fmt.Printf("Deleted %v expired tokens.\n", res.RowsAffected()) + + fmt.Printf("Setting owner_id on onetimetoken\n") + _, err = tx.Exec(ctx, ` + UPDATE handmade_onetimetoken + SET owner_id = (SELECT id FROM auth_user WHERE username = (SELECT username FROM handmade_userpending WHERE activation_token_id = handmade_onetimetoken.id LIMIT 1)) + WHERE token_type = 1; + `) + if err != nil { + return oops.New(err, "failed to set owner_id on onetimetoken") + } + _, err = tx.Exec(ctx, ` + UPDATE handmade_onetimetoken + SET owner_id = (SELECT user_id FROM handmade_passwordresetrequest WHERE confirmation_token_id = handmade_onetimetoken.id) + WHERE token_type = 2; + `) + if err != nil { + return oops.New(err, "failed to set owner_id on onetimetoken") + } + + fmt.Printf("Setting registration_ip on auth_user\n") + _, err = tx.Exec(ctx, ` + UPDATE auth_user + SET registration_ip = (SELECT ip FROM handmade_userpending WHERE handmade_userpending.username = auth_user.username LIMIT 1); + `) + if err != nil { + return oops.New(err, "failed to set owner_id on onetimetoken") + } + + return nil +} + +func (m DeleteInactiveUsers) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/migration/migrations/2021-08-08T141819Z_FinalizeOneTimeTokenChanges.go b/src/migration/migrations/2021-08-08T141819Z_FinalizeOneTimeTokenChanges.go new file mode 100644 index 0000000..18b36a5 --- /dev/null +++ b/src/migration/migrations/2021-08-08T141819Z_FinalizeOneTimeTokenChanges.go @@ -0,0 +1,60 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(FinalizeOneTimeTokenChanges{}) +} + +type FinalizeOneTimeTokenChanges struct{} + +func (m FinalizeOneTimeTokenChanges) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 8, 8, 14, 18, 19, 0, time.UTC)) +} + +func (m FinalizeOneTimeTokenChanges) Name() string { + return "FinalizeOneTimeTokenChanges" +} + +func (m FinalizeOneTimeTokenChanges) Description() string { + return "Create an index and set not-null" +} + +func (m FinalizeOneTimeTokenChanges) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE handmade_onetimetoken + ALTER COLUMN owner_id SET NOT NULL; + + CREATE INDEX handmade_onetimetoken_ownerid_type ON handmade_onetimetoken(owner_id, token_type); + `) + if err != nil { + return oops.New(err, "Create index on onetimetoken") + } + + _, err = tx.Exec(ctx, ` + DROP TABLE handmade_userpending; + `) + if err != nil { + return oops.New(err, "failed to drop handmade_userpending") + } + + _, err = tx.Exec(ctx, ` + DROP TABLE handmade_passwordresetrequest; + `) + if err != nil { + return oops.New(err, "failed to drop handmade_passwordresetrequest") + } + + return nil +} + +func (m FinalizeOneTimeTokenChanges) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/models/onetimetoken.go b/src/models/onetimetoken.go new file mode 100644 index 0000000..f2e675b --- /dev/null +++ b/src/models/onetimetoken.go @@ -0,0 +1,27 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type OneTimeTokenType int + +const ( + TokenTypeRegistration OneTimeTokenType = iota + 1 + TokenTypePasswordReset +) + +type OneTimeToken struct { + ID int `db:"id"` + OwnerID int `db:"owner_id"` + Type OneTimeTokenType `db:"token_type` + Created time.Time `db:"created"` + Expires time.Time `db:"expires"` + Content string `db:"token_content"` +} + +func GenerateToken() string { + return uuid.New().String() +} diff --git a/src/models/user.go b/src/models/user.go index bdb6d58..3d4c8d4 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -7,6 +7,14 @@ import ( var UserType = reflect.TypeOf(User{}) +type UserStatus int + +const ( + UserStatusInactive UserStatus = iota + 1 + UserStatusActive + UserStatusBanned +) + type User struct { ID int `db:"id"` @@ -17,8 +25,8 @@ type User struct { DateJoined time.Time `db:"date_joined"` LastLogin *time.Time `db:"last_login"` - IsStaff bool `db:"is_staff"` - IsActive bool `db:"is_active"` + IsStaff bool `db:"is_staff"` + Status UserStatus `db:"status"` Name string `db:"name"` Bio string `db:"bio"` @@ -26,10 +34,8 @@ type User struct { Signature string `db:"signature"` Avatar *string `db:"avatar"` // TODO: Image field stuff? - DarkTheme bool `db:"darktheme"` - Timezone string `db:"timezone"` - ProfileColor1 string `db:"color_1"` - ProfileColor2 string `db:"color_2"` + DarkTheme bool `db:"darktheme"` + Timezone string `db:"timezone"` ShowEmail bool `db:"showemail"` CanEditLibrary bool `db:"edit_library"` @@ -39,3 +45,7 @@ type User struct { MarkedAllReadAt time.Time `db:"marked_all_read_at"` } + +func (u *User) IsActive() bool { + return u.Status == UserStatusActive +} diff --git a/src/perf/perf.go b/src/perf/perf.go index 018bb91..a7af05f 100644 --- a/src/perf/perf.go +++ b/src/perf/perf.go @@ -2,7 +2,10 @@ package perf import ( "context" + "fmt" "time" + + "github.com/rs/zerolog" ) type RequestPerf struct { @@ -132,3 +135,15 @@ func (perfCollector *PerfCollector) GetPerfCopy() *PerfStorage { perfStorageCopy := <-resultChan return &perfStorageCopy } + +func LogPerf(perf *RequestPerf, log *zerolog.Event) { + blockStack := make([]time.Time, 0) + for i, block := range perf.Blocks { + for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) { + blockStack = blockStack[:len(blockStack)-1] + } + log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs())) + blockStack = append(blockStack, block.End) + } + log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", perf.Method, perf.Path, float64(perf.End.Sub(perf.Start).Nanoseconds())/1000/1000)) +} diff --git a/src/rawdata/scss/_notices.scss b/src/rawdata/scss/_notices.scss index e4c20f0..bd91dc1 100644 --- a/src/rawdata/scss/_notices.scss +++ b/src/rawdata/scss/_notices.scss @@ -33,3 +33,7 @@ .notice-success { @include usevar(background-color, notice-success-color); } + +.notice-failure { + @include usevar(background-color, notice-failure-color); +} diff --git a/src/rawdata/scss/themes/dark/_variables.scss b/src/rawdata/scss/themes/dark/_variables.scss index e4c7404..e02f3f8 100644 --- a/src/rawdata/scss/themes/dark/_variables.scss +++ b/src/rawdata/scss/themes/dark/_variables.scss @@ -50,6 +50,7 @@ $vars: ( notice-lts-color: #2a681d, notice-lts-reqd-color: #876327, notice-success-color: #2a681d, + notice-failure-color: #7a2020, optionbar-border-color: #333, diff --git a/src/rawdata/scss/themes/light/_variables.scss b/src/rawdata/scss/themes/light/_variables.scss index 520f14e..df360d2 100644 --- a/src/rawdata/scss/themes/light/_variables.scss +++ b/src/rawdata/scss/themes/light/_variables.scss @@ -50,6 +50,7 @@ $vars: ( notice-lts-color: #43a52f, notice-lts-reqd-color: #aa7d30, notice-success-color: #43a52f, + notice-failure-color: #b42222, optionbar-border-color: #ccc, diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 3f780ae..9c14599 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -160,10 +160,8 @@ func UserToTemplate(u *models.User, currentTheme string) User { AvatarUrl: UserAvatarUrl(u, currentTheme), ProfileUrl: hmnurl.BuildUserProfile(u.Username), - DarkTheme: u.DarkTheme, - Timezone: u.Timezone, - ProfileColor1: u.ProfileColor1, - ProfileColor2: u.ProfileColor2, + DarkTheme: u.DarkTheme, + Timezone: u.Timezone, CanEditLibrary: u.CanEditLibrary, DiscordSaveShowcase: u.DiscordSaveShowcase, diff --git a/src/templates/src/auth_email_validation.html b/src/templates/src/auth_email_validation.html new file mode 100644 index 0000000..658ee59 --- /dev/null +++ b/src/templates/src/auth_email_validation.html @@ -0,0 +1,19 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+
+ + + +

Hi, {{ .Username }}!

+

You're almost done signing up.

+

To complete your registration and log in, please enter the password you used during the registration process.

+ {{/*NOTE: The username field isn't `type="hidden"` because this way browser will offer to save the username along with the password */}} + + + + +
+
+{{ end }} diff --git a/src/templates/src/auth_email_validation_success.html b/src/templates/src/auth_email_validation_success.html new file mode 100644 index 0000000..13c9ccf --- /dev/null +++ b/src/templates/src/auth_email_validation_success.html @@ -0,0 +1,13 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

Welcome to Handmade Network, {{ .User.Name }}

+

Here's some interesting stuff you can find here:

+ + {{/* TODO(asaf): Describe what users can do on the network? */}} +
+{{ end }} diff --git a/src/templates/src/auth_login.html b/src/templates/src/auth_login.html new file mode 100644 index 0000000..8a5dc6e --- /dev/null +++ b/src/templates/src/auth_login.html @@ -0,0 +1,25 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

Please enter your username and password

+
+ +

+ + +

+ +

+ + +

+ + Forgot your password? + +

+ +

+
+
+{{ end }} diff --git a/src/templates/src/auth_register.html b/src/templates/src/auth_register.html new file mode 100644 index 0000000..143c997 --- /dev/null +++ b/src/templates/src/auth_register.html @@ -0,0 +1,55 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+
+ {{/* NOTE(asaf): No CSRF on register. We don't have a user session yet and I don't think we would gain anything from a pre-login session here */}} + +

+ + + Required. You may use up to 30 characters. Must start with a letter or number. Dashes and underscores are allowed. +

+ +

+ + + Optional. +

+ +

+ + +

+ +

+ + +

+ +

+ + + Enter the same password as before, for verification. +

+ + {{/* TODO(asaf): Consider adding some bot-mitigation thing here */}} + +

+ +

+
+ +
+{{ end }} diff --git a/src/templates/src/auth_register_success.html b/src/templates/src/auth_register_success.html new file mode 100644 index 0000000..a7d6dc8 --- /dev/null +++ b/src/templates/src/auth_register_success.html @@ -0,0 +1,11 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

Hi! You're almost done signing up.

+

We've sent you an email with a confirmation link. Please follow it to complete the registration process.

+

If for some reason the email doesn't arrive in a timely fashion and you also can't find it in your spam trap,
+ you should feel free to contact the staff and ask us to activate you manually.
+ You'll want to tell us the username you chose and preferably email us from the same address you used to sign up.

+
+{{ end }} diff --git a/src/templates/src/email_registration.html b/src/templates/src/email_registration.html new file mode 100644 index 0000000..9dd4095 --- /dev/null +++ b/src/templates/src/email_registration.html @@ -0,0 +1,13 @@ +

Hello, {{ .Name }}

+
+

Welcome to Handmade Network.

+

To complete the registration process, please use the following link: {{ .CompleteRegistrationUrl }}.

+
+

Thanks,
+The Handmade Network staff.

+ +
+

+You are receiving this email because someone registered with your email address at Handmade.Network.
+If that wasn't you, kindly ignore this email. If you do not complete the registration, your information will be deleted from our servers after 7 days. +

diff --git a/src/templates/src/layouts/base.html b/src/templates/src/layouts/base.html index b7e2555..e533a67 100644 --- a/src/templates/src/layouts/base.html +++ b/src/templates/src/layouts/base.html @@ -80,6 +80,7 @@
{{ template "header.html" . }} + {{ template "notices.html" .Notices }} {{ with .Breadcrumbs }}
{{ range $i, $e := . -}} diff --git a/src/templates/src/podcast_edit.html b/src/templates/src/podcast_edit.html index dbcda7f..c5df98e 100644 --- a/src/templates/src/podcast_edit.html +++ b/src/templates/src/podcast_edit.html @@ -2,7 +2,6 @@ {{ define "content" }}
- {{ template "notices.html" .Notices }}
{{ csrftoken .Session }} diff --git a/src/templates/src/podcast_episode_edit.html b/src/templates/src/podcast_episode_edit.html index ccdedaa..d4ac728 100644 --- a/src/templates/src/podcast_episode_edit.html +++ b/src/templates/src/podcast_episode_edit.html @@ -2,7 +2,6 @@ {{ define "content" }}
- {{ template "notices.html" .Notices }}

{{ if .IsEdit }}Edit{{ else }}New{{ end }} Episode

{{ csrftoken .Session }} diff --git a/src/templates/src/project_homepage.html b/src/templates/src/project_homepage.html index 9a5f119..0503db6 100644 --- a/src/templates/src/project_homepage.html +++ b/src/templates/src/project_homepage.html @@ -9,7 +9,6 @@ {{ define "content" }}
- {{ template "notices.html" .Notices }} {{ with .Screenshots }}