From a32f71f86281e460cd37451925fab523badf28df Mon Sep 17 00:00:00 2001 From: Asaf Gartner Date: Tue, 17 Aug 2021 08:18:04 +0300 Subject: [PATCH] Password reset and Notices --- src/admintools/admintools.go | 3 + src/auth/auth.go | 33 +- src/email/email.go | 32 ++ src/hmnurl/hmnurl_test.go | 13 +- src/hmnurl/urls.go | 40 +- src/models/user.go | 7 + src/templates/mapping.go | 10 +- src/templates/src/auth_do_password_reset.html | 41 ++ src/templates/src/auth_email_validation.html | 4 +- src/templates/src/auth_password_reset.html | 26 + .../src/auth_password_reset_sent.html | 9 + src/templates/src/auth_register.html | 7 +- src/templates/src/email_password_reset.html | 12 + src/templates/src/email_registration.html | 6 +- src/templates/src/include/notices.html | 12 +- src/templates/types.go | 7 + src/website/auth.go | 443 +++++++++++++----- src/website/landing.go | 4 - src/website/podcast.go | 18 +- src/website/projects.go | 54 ++- src/website/requesthandling.go | 12 +- src/website/routes.go | 97 +++- src/website/timeline_helper.go | 4 +- 23 files changed, 698 insertions(+), 196 deletions(-) create mode 100644 src/templates/src/auth_do_password_reset.html create mode 100644 src/templates/src/auth_password_reset.html create mode 100644 src/templates/src/auth_password_reset_sent.html create mode 100644 src/templates/src/email_password_reset.html diff --git a/src/admintools/admintools.go b/src/admintools/admintools.go index a414e6e..952c47d 100644 --- a/src/admintools/admintools.go +++ b/src/admintools/admintools.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "time" "git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/db" @@ -114,6 +115,8 @@ func init() { switch emailType { case "registration": err = email.SendRegistrationEmail(toAddress, toName, "test_user", "test_token", p) + case "passwordreset": + err = email.SendPasswordReset(toAddress, toName, "test_user", "test_token", time.Now().Add(time.Hour*24), p) default: fmt.Printf("You must provide a valid email type\n\n") cmd.Usage() diff --git a/src/auth/auth.go b/src/auth/auth.go index 63c5ced..ba8f669 100644 --- a/src/auth/auth.go +++ b/src/auth/auth.go @@ -198,10 +198,11 @@ func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) 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; + (SELECT COUNT(*) as ct FROM handmade_onetimetoken AS ott WHERE ott.owner_id = auth_user.id AND ott.expires < $2 AND ott.token_type = $3) > 0; `, models.UserStatusInactive, time.Now(), + models.TokenTypeRegistration, ) if err != nil { @@ -211,6 +212,25 @@ func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) return tag.RowsAffected(), nil } +func DeleteExpiredPasswordResets(ctx context.Context, conn *pgxpool.Pool) (int64, error) { + tag, err := conn.Exec(ctx, + ` + DELETE FROM handmade_onetimetoken + WHERE + token_type = $1 + AND expires < $2 + `, + models.TokenTypePasswordReset, + time.Now(), + ) + + if err != nil { + return 0, oops.New(err, "failed to delete expired password resets") + } + + return tag.RowsAffected(), nil +} + func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <-chan struct{} { done := make(chan struct{}) go func() { @@ -226,7 +246,16 @@ func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <- logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users") } } else { - logging.Error().Err(err).Msg("Failed to delete expired sessions") + logging.Error().Err(err).Msg("Failed to delete inactive users") + } + + n, err = DeleteExpiredPasswordResets(ctx, conn) + if err == nil { + if n > 0 { + logging.Info().Int64("num deleted password resets", n).Msg("Deleted expired password resets") + } + } else { + logging.Error().Err(err).Msg("Failed to delete expired password resets") } case <-ctx.Done(): return diff --git a/src/email/email.go b/src/email/email.go index d1f361c..5e32680 100644 --- a/src/email/email.go +++ b/src/email/email.go @@ -49,6 +49,38 @@ func SendRegistrationEmail(toAddress string, toName string, username string, com return nil } +type PasswordResetEmailData struct { + Name string + DoPasswordResetUrl string + Expiration time.Time +} + +func SendPasswordReset(toAddress string, toName string, username string, resetToken string, expiration time.Time, perf *perf.RequestPerf) error { + perf.StartBlock("EMAIL", "Password reset email") + + perf.StartBlock("EMAIL", "Rendering template") + contents, err := renderTemplate("email_password_reset.html", PasswordResetEmailData{ + Name: toName, + DoPasswordResetUrl: hmnurl.BuildDoPasswordReset(username, resetToken), + Expiration: expiration, + }) + if err != nil { + return err + } + perf.EndBlock() + + perf.StartBlock("EMAIL", "Sending email") + err = sendMail(toAddress, toName, "[handmade.network] Your password reset request", 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 { diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 8076853..4271344 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -28,7 +28,6 @@ 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") } @@ -79,6 +78,12 @@ func TestEmailConfirmation(t *testing.T) { AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token"), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"}) } +func TestPasswordReset(t *testing.T) { + AssertRegexMatch(t, BuildRequestPasswordReset(), RegexRequestPasswordReset, nil) + AssertRegexMatch(t, BuildPasswordResetSent(), RegexPasswordResetSent, nil) + AssertRegexMatch(t, BuildDoPasswordReset("user", "token"), RegexDoPasswordReset, map[string]string{"username": "user", "token": "token"}) +} + func TestStaticPages(t *testing.T) { AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil) AssertRegexMatch(t, BuildAbout(), RegexAbout, nil) @@ -93,6 +98,10 @@ func TestUserProfile(t *testing.T) { AssertRegexMatch(t, BuildUserProfile("test"), RegexUserProfile, map[string]string{"username": "test"}) } +func TestUserSettings(t *testing.T) { + AssertRegexMatch(t, BuildUserSettings("test"), RegexUserSettings, nil) +} + func TestSnippet(t *testing.T) { AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"}) } @@ -134,7 +143,6 @@ func TestPodcast(t *testing.T) { func TestPodcastEdit(t *testing.T) { AssertRegexMatch(t, BuildPodcastEdit(""), RegexPodcastEdit, nil) - AssertRegexMatch(t, BuildPodcastEditSuccess(""), RegexPodcastEdit, nil) } func TestPodcastEpisode(t *testing.T) { @@ -147,7 +155,6 @@ func TestPodcastEpisodeNew(t *testing.T) { func TestPodcastEpisodeEdit(t *testing.T) { AssertRegexMatch(t, BuildPodcastEpisodeEdit("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"}) - AssertRegexMatch(t, BuildPodcastEpisodeEditSuccess("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"}) } func TestPodcastRSS(t *testing.T) { diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 737435f..9e0a072 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -23,10 +23,6 @@ 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) @@ -102,13 +98,28 @@ func BuildEmailConfirmation(username, token string) string { return Url(fmt.Sprintf("/email_confirmation/%s/%s", url.PathEscape(username), token), nil) } -var RegexPasswordResetRequest = regexp.MustCompile("^/password_reset$") +var RegexRequestPasswordReset = regexp.MustCompile("^/password_reset$") -func BuildPasswordResetRequest() string { +func BuildRequestPasswordReset() string { defer CatchPanic() return Url("/password_reset", nil) } +var RegexPasswordResetSent = regexp.MustCompile("^/password_reset/sent$") + +func BuildPasswordResetSent() string { + defer CatchPanic() + return Url("/password_reset/sent", nil) +} + +var RegexOldDoPasswordReset = regexp.MustCompile(`^_password_reset/(?P[\w\ \.\,\-@\+\_]+)/(?P[\d\w]+)[\/]?$`) +var RegexDoPasswordReset = regexp.MustCompile("^/password_reset/(?P[^/]+)/(?P[^/]+)$") + +func BuildDoPasswordReset(username string, token string) string { + defer CatchPanic() + return Url(fmt.Sprintf("/password_reset/%s/%s", url.PathEscape(username), token), nil) +} + /* * Static Pages */ @@ -176,9 +187,10 @@ func BuildUserProfile(username string) string { return Url("/m/"+url.PathEscape(username), nil) } -// TODO -func BuildUserSettings(username string) string { - return "" +var RegexUserSettings = regexp.MustCompile(`^/_settings$`) + +func BuildUserSettings(section string) string { + return ProjectUrlWithFragment("/_settings", nil, "", section) } /* @@ -291,11 +303,6 @@ func BuildPodcastEdit(projectSlug string) string { return ProjectUrl("/podcast/edit", nil, projectSlug) } -func BuildPodcastEditSuccess(projectSlug string) string { - defer CatchPanic() - return ProjectUrl("/podcast/edit", []Q{Q{"success", "true"}}, projectSlug) -} - var RegexPodcastEpisode = regexp.MustCompile(`^/podcast/ep/(?P[^/]+)$`) func BuildPodcastEpisode(projectSlug string, episodeGUID string) string { @@ -317,11 +324,6 @@ func BuildPodcastEpisodeEdit(projectSlug string, episodeGUID string) string { return ProjectUrl(fmt.Sprintf("/podcast/ep/%s/edit", episodeGUID), nil, projectSlug) } -func BuildPodcastEpisodeEditSuccess(projectSlug string, episodeGUID string) string { - defer CatchPanic() - return ProjectUrl(fmt.Sprintf("/podcast/ep/%s/edit", episodeGUID), []Q{Q{"success", "true"}}, projectSlug) -} - var RegexPodcastRSS = regexp.MustCompile(`^/podcast/podcast.xml$`) func BuildPodcastRSS(projectSlug string) string { diff --git a/src/models/user.go b/src/models/user.go index 3d4c8d4..4d23585 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -46,6 +46,13 @@ type User struct { MarkedAllReadAt time.Time `db:"marked_all_read_at"` } +func (u *User) BestName() string { + if u.Name != "" { + return u.Name + } + return u.Username +} + func (u *User) IsActive() bool { return u.Status == UserStatusActive } diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 3398161..0d795ce 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -126,14 +126,6 @@ func UserAvatarUrl(u *models.User, currentTheme string) string { return avatar } -func UserDisplayName(u *models.User) string { - name := u.Name - if u.Name == "" { - name = u.Username - } - return name -} - func UserToTemplate(u *models.User, currentTheme string) User { if u == nil { return User{ @@ -154,7 +146,7 @@ func UserToTemplate(u *models.User, currentTheme string) User { Email: email, IsStaff: u.IsStaff, - Name: UserDisplayName(u), + Name: u.BestName(), Blurb: u.Blurb, Signature: u.Signature, DateJoined: u.DateJoined, diff --git a/src/templates/src/auth_do_password_reset.html b/src/templates/src/auth_do_password_reset.html new file mode 100644 index 0000000..3bfa7ad --- /dev/null +++ b/src/templates/src/auth_do_password_reset.html @@ -0,0 +1,41 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+
+ + {{/*NOTE: The username field isn't `type="hidden"` because this way browser will offer to save the username along with the password */}} + + +

Hi, {{ .Username }}!

+

Please enter a new password

+

+ + +

+ +

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

+ + +
+
+ +{{ end }} diff --git a/src/templates/src/auth_email_validation.html b/src/templates/src/auth_email_validation.html index 658ee59..97e9b5a 100644 --- a/src/templates/src/auth_email_validation.html +++ b/src/templates/src/auth_email_validation.html @@ -4,12 +4,12 @@
+ {{/*NOTE: The username field isn't `type="hidden"` because this way browser will offer to save the username along with the password */}} + -

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 */}} diff --git a/src/templates/src/auth_password_reset.html b/src/templates/src/auth_password_reset.html new file mode 100644 index 0000000..e332ec9 --- /dev/null +++ b/src/templates/src/auth_password_reset.html @@ -0,0 +1,26 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

Request a password reset link

+ +

+ + +

+ +

+ + +

+ +

+ +

+ +

+ Note: To avoid your being spammed with password reset links, we limit the number of requests per account every 24 hours. +

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

A password reset link was sent. It'll expire in 24 hours.

+

If for some reason the email shouldn't arrive in a timely fashion, and it also doesn't show up in your spam trap, please contact the staff.

+

* Security best practices prevent us from disclosing whether or not the username and email combination was actually valid. An email may or may not have been sent depending on whether it was or wasn't.

+
+{{ end }} diff --git a/src/templates/src/auth_register.html b/src/templates/src/auth_register.html index 143c997..0cfe7ca 100644 --- a/src/templates/src/auth_register.html +++ b/src/templates/src/auth_register.html @@ -43,13 +43,16 @@ let form = document.querySelector("#register_form") let password1 = form.querySelector("input[name=password]") let password2 = form.querySelector("input[name=password2]") - password2.addEventListener("input", function(ev) { + password1.addEventListener("input", validatePasswordConfirmation); + password2.addEventListener("input", validatePasswordConfirmation); + + function validatePasswordConfirmation(ev) { if (password1.value != password2.value) { password2.setCustomValidity("Password doesn't match"); } else { password2.setCustomValidity(""); } - }); + }
{{ end }} diff --git a/src/templates/src/email_password_reset.html b/src/templates/src/email_password_reset.html new file mode 100644 index 0000000..6b25146 --- /dev/null +++ b/src/templates/src/email_password_reset.html @@ -0,0 +1,12 @@ +

Hello, {{ .Name }}

+

Someone has requested a password reset for your account.
+We hope it was you. If you didn't request it, we apologise. Kindly ignore this message.

+

To finish the password reset, visit: {{ .DoPasswordResetUrl }}
+This link will be valid for 24 hours (until {{ absolutedate .Expiration }} UTC)

+

Thanks,
+The Handmade Network staff.

+ +
+

+You are receiving this email because someone requested a password reset for your account and supplied both your username and email address correctly. +

diff --git a/src/templates/src/email_registration.html b/src/templates/src/email_registration.html index 9dd4095..0b00ef3 100644 --- a/src/templates/src/email_registration.html +++ b/src/templates/src/email_registration.html @@ -1,8 +1,6 @@

Hello, {{ .Name }}

-
-

Welcome to Handmade Network.

-

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

-
+

Welcome to Handmade Network.
+To complete the registration process, please use the following link: {{ .CompleteRegistrationUrl }}.

Thanks,
The Handmade Network staff.

diff --git a/src/templates/src/include/notices.html b/src/templates/src/include/notices.html index 4c08134..71abcf1 100644 --- a/src/templates/src/include/notices.html +++ b/src/templates/src/include/notices.html @@ -1,5 +1,7 @@ -{{ range . }} -
- {{ .Content }} -
-{{ end }} +
+ {{ range . }} +
+ {{ .Content }} +
+ {{ end }} +
diff --git a/src/templates/types.go b/src/templates/types.go index bcc90a9..3759720 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -29,6 +29,13 @@ type BaseData struct { MathjaxEnabled bool } +func (bd *BaseData) AddImmediateNotice(class, content string) { + bd.Notices = append(bd.Notices, Notice{ + Class: class, + Content: template.HTML(content), + }) +} + type Header struct { AdminUrl string UserSettingsUrl string diff --git a/src/website/auth.go b/src/website/auth.go index e6e010a..821bf53 100644 --- a/src/website/auth.go +++ b/src/website/auth.go @@ -3,7 +3,6 @@ package website import ( "errors" "fmt" - "html/template" "net/http" "regexp" "strings" @@ -41,7 +40,7 @@ func LoginPage(c *RequestContext) ResponseData { res.MustWriteTemplate("auth_login.html", LoginPageData{ BaseData: getBaseData(c), RedirectUrl: c.Req.URL.Query().Get("redirect"), - ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(), + ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(), }, c.Perf) return res } @@ -57,29 +56,33 @@ func Login(c *RequestContext) ResponseData { return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "request must contain form data")) } - username := form.Get("username") - password := form.Get("password") - if username == "" || password == "" { - return RejectRequest(c, "You must provide both a username and password") - } - redirect := form.Get("redirect") if redirect == "" { redirect = "/" } + username := form.Get("username") + password := form.Get("password") + if username == "" || password == "" { + return c.Redirect(hmnurl.BuildLoginPage(redirect), http.StatusSeeOther) + } + + showLoginWithFailure := func(c *RequestContext, redirect string) ResponseData { + var res ResponseData + baseData := getBaseData(c) + baseData.AddImmediateNotice("failure", "Incorrect username or password") + res.MustWriteTemplate("auth_login.html", LoginPageData{ + BaseData: baseData, + RedirectUrl: redirect, + ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(), + }, c.Perf) + return res + } + userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE LOWER(username) = LOWER($1)", username) if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { - var res ResponseData - baseData := getBaseData(c) - baseData.Notices = []templates.Notice{{Content: "Incorrect username or password", Class: "failure"}} - res.MustWriteTemplate("auth_login.html", LoginPageData{ - BaseData: baseData, - RedirectUrl: redirect, - ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(), - }, c.Perf) - return res + return showLoginWithFailure(c, redirect) } else { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username")) } @@ -93,15 +96,7 @@ func Login(c *RequestContext) ResponseData { } if !success { - var res ResponseData - baseData := getBaseData(c) - baseData.Notices = []templates.Notice{{Content: "Incorrect username or password", Class: "failure"}} - res.MustWriteTemplate("auth_login.html", LoginPageData{ - BaseData: baseData, - RedirectUrl: redirect, - ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(), - }, c.Perf) - return res + return showLoginWithFailure(c, redirect) } if user.Status == models.UserStatusInactive { @@ -117,23 +112,13 @@ func Login(c *RequestContext) ResponseData { } func Logout(c *RequestContext) ResponseData { - sessionCookie, err := c.Req.Cookie(auth.SessionCookieName) - if err == nil { - // clear the session from the db immediately, no expiration - err := auth.DeleteSession(c.Context(), c.Conn, sessionCookie.Value) - if err != nil { - logging.Error().Err(err).Msg("failed to delete session on logout") - } - } - redir := c.Req.URL.Query().Get("redirect") if redir == "" { redir = "/" } res := c.Redirect(redir, http.StatusSeeOther) - res.SetCookie(auth.DeleteSessionCookie) - + logoutUser(c, &res) return res } @@ -171,6 +156,16 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData { return RejectRequest(c, "Password confirmation doesn't match password") } + c.Perf.StartBlock("SQL", "Check blacklist") + // TODO(asaf): Check email against blacklist + blacklisted := false + if blacklisted { + // NOTE(asaf): Silent rejection so we don't allow attackers to harvest emails. + time.Sleep(time.Second * 3) // NOTE(asaf): Pretend to send email + return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther) + } + c.Perf.EndBlock() + c.Perf.StartBlock("SQL", "Check for existing usernames and emails") userAlreadyExists := true _, err := db.QueryInt(c.Context(), c.Conn, @@ -222,16 +217,6 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password")) } - c.Perf.StartBlock("SQL", "Check blacklist") - // TODO(asaf): Check email against blacklist - blacklisted := false - if blacklisted { - // NOTE(asaf): Silent rejection so we don't allow attackers to harvest emails. - time.Sleep(time.Second * 3) // NOTE(asaf): Pretend to send email - return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther) - } - c.Perf.EndBlock() - c.Perf.StartBlock("SQL", "Create user and one time token") tx, err := c.Conn.Begin(c.Context()) if err != nil { @@ -340,9 +325,9 @@ func EmailConfirmation(c *RequestContext) ResponseData { return RejectRequest(c, "Bad validation url") } - validationResult := validateUsernameAndToken(c, username, token) - if !validationResult.IsValid { - return validationResult.InvalidResponse + validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration) + if !validationResult.Match { + return makeResponseForBadRegistrationTokenValidationResult(c, validationResult) } var res ResponseData @@ -361,9 +346,9 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData { username := c.Req.Form.Get("username") password := c.Req.Form.Get("password") - validationResult := validateUsernameAndToken(c, username, token) - if !validationResult.IsValid { - return validationResult.InvalidResponse + validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration) + if !validationResult.Match { + return makeResponseForBadRegistrationTokenValidationResult(c, validationResult) } success, err := tryLogin(c, validationResult.User, password) @@ -374,7 +359,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData { var res ResponseData baseData := getBaseData(c) // NOTE(asaf): We can report that the password is incorrect, because an attacker wouldn't have a valid token to begin with. - baseData.Notices = []templates.Notice{{Content: template.HTML("Incorrect password. Please try again."), Class: "failure"}} + baseData.AddImmediateNotice("failure", "Incorrect password. Please try again.") res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{ BaseData: getBaseData(c), Token: token, @@ -419,7 +404,8 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData { } c.Perf.EndBlock() - res := c.Redirect(hmnurl.BuildHomepageWithRegistrationSuccess(), http.StatusSeeOther) + res := c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) + res.AddFutureNotice("success", "You've completed your registration successfully!") err = loginUser(c, validationResult.User, &res) if err != nil { return ErrorResponse(http.StatusInternalServerError, err) @@ -427,41 +413,281 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData { return res } +// NOTE(asaf): Only call this when validationResult.Match is false. +func makeResponseForBadRegistrationTokenValidationResult(c *RequestContext, validationResult validateUserAndTokenResult) ResponseData { + if validationResult.User == nil { + return RejectRequest(c, "You haven't validated your email in time and your user was deleted. You may try registering again with the same username.") + } + + if validationResult.OneTimeToken == nil { + // NOTE(asaf): The user exists, but the validation token doesn't. + // That means the user already validated their email and can just log in normally. + return c.Redirect(hmnurl.BuildLoginPage(""), http.StatusSeeOther) + } + + return RejectRequest(c, "Bad token. If you are having problems registering or logging in, please contact the staff.") +} + // NOTE(asaf): PasswordReset refers specifically to "forgot your password" flow over email, // not to changing your password through the user settings page. func RequestPasswordReset(c *RequestContext) ResponseData { - // Verify not logged in - // Show form - return FourOhFour(c) + if c.CurrentUser != nil { + return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) + } + var res ResponseData + res.MustWriteTemplate("auth_password_reset.html", getBaseData(c), c.Perf) + return res } func RequestPasswordResetSubmit(c *RequestContext) ResponseData { - // Verify not logged in - // Verify email input - // Potentially send password reset email if no password reset request is active - // Show success page in all cases - return FourOhFour(c) + if c.CurrentUser != nil { + return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) + } + c.Req.ParseForm() + + username := strings.TrimSpace(c.Req.Form.Get("username")) + emailAddress := strings.TrimSpace(c.Req.Form.Get("email")) + + if username == "" && emailAddress == "" { + return RejectRequest(c, "You must provide a username and an email address.") + } + + var user *models.User + + c.Perf.StartBlock("SQL", "Fetching user") + userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, + ` + SELECT $columns + FROM auth_user + WHERE + LOWER(username) = LOWER($1) + AND LOWER(email) = LOWER($2) + `, + username, + emailAddress, + ) + c.Perf.EndBlock() + if err != nil { + if !errors.Is(err, db.ErrNoMatchingRows) { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username")) + } + } + if userRow != nil { + user = userRow.(*models.User) + } + + if user != nil { + c.Perf.StartBlock("SQL", "Fetching existing token") + tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{}, + ` + SELECT $columns + FROM handmade_onetimetoken + WHERE + token_type = $1 + AND owner_id = $2 + `, + models.TokenTypePasswordReset, + user.ID, + ) + c.Perf.EndBlock() + if err != nil { + if !errors.Is(err, db.ErrNoMatchingRows) { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch onetimetoken for user")) + } + } + var resetToken *models.OneTimeToken + if tokenRow != nil { + resetToken = tokenRow.(*models.OneTimeToken) + } + now := time.Now() + + if resetToken != nil { + if resetToken.Expires.Before(now.Add(time.Minute * 30)) { // NOTE(asaf): Expired or about to expire + c.Perf.StartBlock("SQL", "Deleting expired token") + _, err = c.Conn.Exec(c.Context(), + ` + DELETE FROM handmade_onetimetoken + WHERE id = $1 + `, + resetToken.ID, + ) + c.Perf.EndBlock() + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete onetimetoken")) + } + resetToken = nil + } + } + + if resetToken == nil { + c.Perf.StartBlock("SQL", "Creating new token") + tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{}, + ` + INSERT INTO handmade_onetimetoken (token_type, created, expires, token_content, owner_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING $columns + `, + models.TokenTypePasswordReset, + now, + now.Add(time.Hour*24), + models.GenerateToken(), + user.ID, + ) + c.Perf.EndBlock() + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create onetimetoken")) + } + resetToken = tokenRow.(*models.OneTimeToken) + + err = email.SendPasswordReset(user.Email, user.BestName(), user.Username, resetToken.Content, resetToken.Expires, c.Perf) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to send email")) + } + } + } + return c.Redirect(hmnurl.BuildPasswordResetSent(), http.StatusSeeOther) } -func PasswordReset(c *RequestContext) ResponseData { - // Verify reset token - // If logged in - // If same user as reset request -> Mark password reset as resolved and redirect to user settings - // If other user -> Log out - // Show form - return FourOhFour(c) +type PasswordResetSentData struct { + templates.BaseData + ContactUsUrl string } -func PasswordResetSubmit(c *RequestContext) ResponseData { - // Verify not logged in - // Verify reset token - // Verify not resolved - // Verify inputs - // Set new password - // Mark resolved - // Log in - // Redirect to user settings page - return FourOhFour(c) +func PasswordResetSent(c *RequestContext) ResponseData { + if c.CurrentUser != nil { + return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther) + } + var res ResponseData + res.MustWriteTemplate("auth_password_reset_sent.html", PasswordResetSentData{ + BaseData: getBaseData(c), + ContactUsUrl: hmnurl.BuildContactPage(), + }, c.Perf) + return res +} + +type DoPasswordResetData struct { + templates.BaseData + Username string + Token string +} + +func DoPasswordReset(c *RequestContext) ResponseData { + username, hasUsername := c.PathParams["username"] + token, hasToken := c.PathParams["token"] + + if !hasToken || !hasUsername { + return RejectRequest(c, "Bad validation url.") + } + + validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset) + if !validationResult.Match { + return RejectRequest(c, "Bad validation url.") + } + + var res ResponseData + + if c.CurrentUser != nil && c.CurrentUser.ID != validationResult.User.ID { + // NOTE(asaf): In the rare case that a user is logged in with user A and is trying to + // change the password for user B, log out the current user to avoid confusion. + logoutUser(c, &res) + } + + res.MustWriteTemplate("auth_do_password_reset.html", DoPasswordResetData{ + BaseData: getBaseData(c), + Username: username, + Token: token, + }, c.Perf) + return res +} + +func DoPasswordResetSubmit(c *RequestContext) ResponseData { + c.Req.ParseForm() + + token := c.Req.Form.Get("token") + username := c.Req.Form.Get("username") + password := c.Req.Form.Get("password") + password2 := c.Req.Form.Get("password2") + + validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset) + if !validationResult.Match { + return RejectRequest(c, "Bad validation url.") + } + + if c.CurrentUser != nil && c.CurrentUser.ID != validationResult.User.ID { + return RejectRequest(c, fmt.Sprintf("Can't change password for %s. You are logged in as %s.", username, c.CurrentUser.Username)) + } + + if len(password) < 8 { + return RejectRequest(c, "Password too short") + } + if password != password2 { + return RejectRequest(c, "Password confirmation doesn't match password") + } + + hashed, err := auth.HashPassword(password) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password")) + } + + c.Perf.StartBlock("SQL", "Update user's password and delete reset token") + tx, err := c.Conn.Begin(c.Context()) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction")) + } + defer tx.Rollback(c.Context()) + + tag, err := tx.Exec(c.Context(), + ` + UPDATE auth_user + SET password = $1 + WHERE id = $2 + `, + hashed.String(), + validationResult.User.ID, + ) + if err != nil || tag.RowsAffected() == 0 { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's password")) + } + + if validationResult.User.Status == models.UserStatusInactive { + _, err = tx.Exec(c.Context(), + ` + UPDATE auth_user + SET status = $1 + WHERE id = $2 + `, + models.UserStatusActive, + validationResult.User.ID, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's status")) + } + } + + _, err = tx.Exec(c.Context(), + ` + DELETE FROM handmade_onetimetoken + WHERE id = $1 + `, + validationResult.OneTimeToken.ID, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete onetimetoken")) + } + + err = tx.Commit(c.Context()) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit password reset to the db")) + } + c.Perf.EndBlock() + + res := c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther) + res.AddFutureNotice("success", "Password changed successfully.") + err = loginUser(c, validationResult.User, &res) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, err) + } + return res } func tryLogin(c *RequestContext, user *models.User, password string) (bool, error) { @@ -535,14 +761,27 @@ func loginUser(c *RequestContext, user *models.User, responseData *ResponseData) return nil } -type validateUserAndTokenResult struct { - User *models.User - OneTimeToken *models.OneTimeToken - IsValid bool - InvalidResponse ResponseData +func logoutUser(c *RequestContext, res *ResponseData) { + sessionCookie, err := c.Req.Cookie(auth.SessionCookieName) + if err == nil { + // clear the session from the db immediately, no expiration + err := auth.DeleteSession(c.Context(), c.Conn, sessionCookie.Value) + if err != nil { + logging.Error().Err(err).Msg("failed to delete session on logout") + } + } + + res.SetCookie(auth.DeleteSessionCookie) } -func validateUsernameAndToken(c *RequestContext, username string, token string) validateUserAndTokenResult { +type validateUserAndTokenResult struct { + User *models.User + OneTimeToken *models.OneTimeToken + Match bool + Error error +} + +func validateUsernameAndToken(c *RequestContext, username string, token string, tokenType models.OneTimeTokenType) validateUserAndTokenResult { c.Perf.StartBlock("SQL", "Check username and token") defer c.Perf.EndBlock() type userAndTokenQuery struct { @@ -554,32 +793,26 @@ func validateUsernameAndToken(c *RequestContext, username string, token string) SELECT $columns FROM auth_user LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id - WHERE LOWER(auth_user.username) = LOWER($1) + WHERE + LOWER(auth_user.username) = LOWER($1) + AND onetimetoken.token_type = $2 `, username, + tokenType, ) var result validateUserAndTokenResult if err != nil { - if errors.Is(err, db.ErrNoMatchingRows) { - result.IsValid = false - result.InvalidResponse = RejectRequest(c, "You haven't validated your email in time and your user was deleted. You may try registering again with the same username.") - } else { - result.IsValid = false - result.InvalidResponse = ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch token from db")) + if !errors.Is(err, db.ErrNoMatchingRows) { + result.Error = oops.New(err, "failed to fetch user and token from db") + return result } - } else { + } + if row != nil { data := row.(*userAndTokenQuery) - if data.OneTimeToken == nil { - // NOTE(asaf): The user exists, but the validation token doesn't. That means the user already validated their email and can just log in normally. - result.IsValid = false - result.InvalidResponse = c.Redirect(hmnurl.BuildLoginPage(""), http.StatusSeeOther) - } else if data.OneTimeToken.Content != token { - result.IsValid = false - result.InvalidResponse = RejectRequest(c, "Bad token. If you are having problems registering or logging in, please contact the staff.") - } else { - result.IsValid = true - result.User = &data.User - result.OneTimeToken = data.OneTimeToken + result.User = &data.User + result.OneTimeToken = data.OneTimeToken + if result.OneTimeToken != nil { + result.Match = (result.OneTimeToken.Content == token) } } diff --git a/src/website/landing.go b/src/website/landing.go index e4276d1..531d428 100644 --- a/src/website/landing.go +++ b/src/website/landing.go @@ -317,10 +317,6 @@ func Index(c *RequestContext) ResponseData { baseData := getBaseData(c) baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more? - if c.Req.URL.Query().Get("registered") != "" { - baseData.Notices = []templates.Notice{{Content: "You've completed your registration successfully!", Class: "success"}} - } - var res ResponseData err = res.WriteTemplate("landing.html", LandingTemplateData{ BaseData: baseData, diff --git a/src/website/podcast.go b/src/website/podcast.go index 96a418a..784bef7 100644 --- a/src/website/podcast.go +++ b/src/website/podcast.go @@ -99,11 +99,6 @@ func PodcastEdit(c *RequestContext) ResponseData { Podcast: podcast, } - success := c.URL().Query().Get("success") - if success != "" { - podcastEditData.BaseData.Notices = append(podcastEditData.BaseData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."}) - } - var res ResponseData err = res.WriteTemplate("podcast_edit.html", podcastEditData, c.Perf) if err != nil { @@ -249,7 +244,9 @@ func PodcastEditSubmit(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to commit db transaction")) } - return c.Redirect(hmnurl.BuildPodcastEditSuccess(c.CurrentProject.Slug), http.StatusSeeOther) + res := c.Redirect(hmnurl.BuildPodcastEdit(c.CurrentProject.Slug), http.StatusSeeOther) + res.AddFutureNotice("success", "Podcast updated successfully.") + return res } type PodcastEpisodeData struct { @@ -388,11 +385,6 @@ func PodcastEpisodeEdit(c *RequestContext) ResponseData { EpisodeFiles: episodeFiles, } - success := c.URL().Query().Get("success") - if success != "" { - podcastEpisodeEditData.BaseData.Notices = append(podcastEpisodeEditData.BaseData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."}) - } - var res ResponseData err = res.WriteTemplate("podcast_episode_edit.html", podcastEpisodeEditData, c.Perf) if err != nil { @@ -541,7 +533,9 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData { } } - return c.Redirect(hmnurl.BuildPodcastEpisodeEditSuccess(c.CurrentProject.Slug, guidStr), http.StatusSeeOther) + res := c.Redirect(hmnurl.BuildPodcastEpisodeEdit(c.CurrentProject.Slug, guidStr), http.StatusSeeOther) + res.AddFutureNotice("success", "Podcast episode updated successfully.") + return res } func GetEpisodeFiles(projectSlug string) ([]string, error) { diff --git a/src/website/projects.go b/src/website/projects.go index e124414..eed0196 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -3,7 +3,6 @@ package website import ( "errors" "fmt" - "html/template" "math" "math/rand" "net/http" @@ -380,39 +379,48 @@ func ProjectHomepage(c *RequestContext) ResponseData { } if project.Flags == 1 { - hiddenNotice := templates.Notice{ - Class: "hidden", - Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.", - } - projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, hiddenNotice) + projectHomepageData.BaseData.AddImmediateNotice( + "hidden", + "NOTICE: This project is hidden. It is currently visible only to owners and site admins.", + ) } if project.Lifecycle != models.ProjectLifecycleActive { - var lifecycleNotice templates.Notice switch project.Lifecycle { case models.ProjectLifecycleUnapproved: - lifecycleNotice.Class = "unapproved" - lifecycleNotice.Content = template.HTML(fmt.Sprintf( - "NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please submit it for approval when the project content is ready for review.", - hmnurl.BuildProjectEdit(project.Slug, "submit"), - )) + projectHomepageData.BaseData.AddImmediateNotice( + "unapproved", + fmt.Sprintf( + "NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please submit it for approval when the project content is ready for review.", + hmnurl.BuildProjectEdit(project.Slug, "submit"), + ), + ) case models.ProjectLifecycleApprovalRequired: - lifecycleNotice.Class = "unapproved" - lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval. It is only visible to owners and site admins.") + projectHomepageData.BaseData.AddImmediateNotice( + "unapproved", + "NOTICE: This project is awaiting approval. It is only visible to owners and site admins.", + ) case models.ProjectLifecycleHiatus: - lifecycleNotice.Class = "hiatus" - lifecycleNotice.Content = template.HTML("NOTICE: This project is on hiatus and may not update for a while.") + projectHomepageData.BaseData.AddImmediateNotice( + "hiatus", + "NOTICE: This project is on hiatus and may not update for a while.", + ) case models.ProjectLifecycleDead: - lifecycleNotice.Class = "dead" - lifecycleNotice.Content = template.HTML("NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.") + projectHomepageData.BaseData.AddImmediateNotice( + "dead", + "NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.", + ) case models.ProjectLifecycleLTSRequired: - lifecycleNotice.Class = "lts-reqd" - lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval for maintenance-mode status.") + projectHomepageData.BaseData.AddImmediateNotice( + "lts-reqd", + "NOTICE: This project is awaiting approval for maintenance-mode status.", + ) case models.ProjectLifecycleLTS: - lifecycleNotice.Class = "lts" - lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.") + projectHomepageData.BaseData.AddImmediateNotice( + "lts", + "NOTICE: This project has reached a state of completion.", + ) } - projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, lifecycleNotice) } for _, screenshot := range screenshotQueryResult.ToSlice() { diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index 06db354..7ec26a7 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "html" + "html/template" "io" "net" "net/http" @@ -241,9 +242,10 @@ func (c *RequestContext) Redirect(dest string, code int) ResponseData { } type ResponseData struct { - StatusCode int - Body *bytes.Buffer - Errors []error + StatusCode int + Body *bytes.Buffer + Errors []error + FutureNotices []templates.Notice header http.Header } @@ -274,6 +276,10 @@ func (rd *ResponseData) SetCookie(cookie *http.Cookie) { rd.Header().Add("Set-Cookie", cookie.String()) } +func (rd *ResponseData) AddFutureNotice(class string, content string) { + rd.FutureNotices = append(rd.FutureNotices, templates.Notice{Class: class, Content: template.HTML(content)}) +} + func (rd *ResponseData) WriteTemplate(name string, data interface{}, rp *perf.RequestPerf) error { if rp != nil { rp.StartBlock("TEMPLATE", name) diff --git a/src/website/routes.go b/src/website/routes.go index 89d1b87..9a19c57 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "html/template" "net/http" "net/url" "strings" @@ -50,6 +51,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt defer LogContextErrors(c, &res) + defer storeNoticesInCookie(c, &res) + ok, errRes := LoadCommonWebsiteData(c) if !ok { return errRes @@ -69,6 +72,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt defer LogContextErrors(c, &res) + defer storeNoticesInCookie(c, &res) + ok, errRes := LoadCommonWebsiteData(c) if !ok { return errRes @@ -153,6 +158,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt mainRoutes.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation) mainRoutes.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit) + mainRoutes.GET(hmnurl.RegexRequestPasswordReset, RequestPasswordReset) + mainRoutes.POST(hmnurl.RegexRequestPasswordReset, RequestPasswordResetSubmit) + mainRoutes.GET(hmnurl.RegexPasswordResetSent, PasswordResetSent) + mainRoutes.GET(hmnurl.RegexOldDoPasswordReset, DoPasswordReset) + mainRoutes.GET(hmnurl.RegexDoPasswordReset, DoPasswordReset) + mainRoutes.POST(hmnurl.RegexDoPasswordReset, DoPasswordResetSubmit) + mainRoutes.GET(hmnurl.RegexFeed, Feed) mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) mainRoutes.GET(hmnurl.RegexShowcase, Showcase) @@ -225,6 +237,8 @@ func getBaseData(c *RequestContext) templates.BaseData { templateSession = &s } + notices := getNoticesFromCookie(c) + return templates.BaseData{ Theme: c.Theme, @@ -235,11 +249,12 @@ func getBaseData(c *RequestContext) templates.BaseData { Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme), User: templateUser, Session: templateSession, + Notices: notices, IsProjectPage: !c.CurrentProject.IsHMN(), Header: templates.Header{ AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf) - UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf) + UserSettingsUrl: hmnurl.BuildUserSettings(""), LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()), LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()), RegisterUrl: hmnurl.BuildRegister(), @@ -479,3 +494,83 @@ func LogContextErrors(c *RequestContext, res *ResponseData) { c.Logger.Error().Timestamp().Stack().Str("Requested", c.FullUrl()).Err(err).Msg("error occurred during request") } } + +const NoticesCookieName = "hmn_notices" + +func getNoticesFromCookie(c *RequestContext) []templates.Notice { + cookie, err := c.Req.Cookie(NoticesCookieName) + if err != nil { + if !errors.Is(err, http.ErrNoCookie) { + c.Logger.Warn().Err(err).Msg("failed to get notices cookie") + } + return nil + } + return deserializeNoticesFromCookie(cookie.Value) +} + +func storeNoticesInCookie(c *RequestContext, res *ResponseData) { + serialized := serializeNoticesForCookie(c, res.FutureNotices) + if serialized != "" { + noticesCookie := http.Cookie{ + Name: NoticesCookieName, + Value: serialized, + Path: "/", + Domain: config.Config.Auth.CookieDomain, + Expires: time.Now().Add(time.Minute * 5), + Secure: config.Config.Auth.CookieSecure, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + res.SetCookie(¬icesCookie) + } else if !(res.StatusCode >= 300 && res.StatusCode < 400) { + // NOTE(asaf): Don't clear on redirect + noticesCookie := http.Cookie{ + Name: NoticesCookieName, + Path: "/", + Domain: config.Config.Auth.CookieDomain, + MaxAge: -1, + } + res.SetCookie(¬icesCookie) + } +} + +func serializeNoticesForCookie(c *RequestContext, notices []templates.Notice) string { + var builder strings.Builder + maxSize := 1024 // NOTE(asaf): Make sure we don't use too much space for notices. + size := 0 + for i, notice := range notices { + sizeIncrease := len(notice.Class) + len(string(notice.Content)) + 1 + if i != 0 { + sizeIncrease += 1 + } + if size+sizeIncrease > maxSize { + c.Logger.Warn().Interface("Notices", notices).Msg("Notices too big for cookie") + break + } + + if i != 0 { + builder.WriteString("\t") + } + builder.WriteString(notice.Class) + builder.WriteString("|") + builder.WriteString(string(notice.Content)) + + size += sizeIncrease + } + return builder.String() +} + +func deserializeNoticesFromCookie(cookieVal string) []templates.Notice { + var result []templates.Notice + notices := strings.Split(cookieVal, "\t") + for _, notice := range notices { + parts := strings.SplitN(notice, "|", 2) + if len(parts) == 2 { + result = append(result, templates.Notice{ + Class: parts[0], + Content: template.HTML(parts[1]), + }) + } + } + return result +} diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go index b8e00c5..4dac16c 100644 --- a/src/website/timeline_helper.go +++ b/src/website/timeline_helper.go @@ -65,7 +65,7 @@ func PostToTimelineItem(lineageBuilder *models.SubforumLineageBuilder, post *mod Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug), OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), - OwnerName: templates.UserDisplayName(owner), + OwnerName: owner.BestName(), OwnerUrl: hmnurl.BuildUserProfile(owner.Username), Description: "", // NOTE(asaf): No description for posts @@ -122,7 +122,7 @@ func SnippetToTimelineItem(snippet *models.Snippet, asset *models.Asset, discord Url: hmnurl.BuildSnippet(snippet.ID), OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), - OwnerName: templates.UserDisplayName(owner), + OwnerName: owner.BestName(), OwnerUrl: hmnurl.BuildUserProfile(owner.Username), Description: template.HTML(snippet.DescriptionHtml),