Password reset and Notices
This commit is contained in:
parent
4c84bd2860
commit
a32f71f862
|
@ -5,6 +5,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/auth"
|
"git.handmade.network/hmn/hmn/src/auth"
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
@ -114,6 +115,8 @@ func init() {
|
||||||
switch emailType {
|
switch emailType {
|
||||||
case "registration":
|
case "registration":
|
||||||
err = email.SendRegistrationEmail(toAddress, toName, "test_user", "test_token", p)
|
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:
|
default:
|
||||||
fmt.Printf("You must provide a valid email type\n\n")
|
fmt.Printf("You must provide a valid email type\n\n")
|
||||||
cmd.Usage()
|
cmd.Usage()
|
||||||
|
|
|
@ -198,10 +198,11 @@ func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error)
|
||||||
DELETE FROM auth_user
|
DELETE FROM auth_user
|
||||||
WHERE
|
WHERE
|
||||||
status = $1 AND
|
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,
|
models.UserStatusInactive,
|
||||||
time.Now(),
|
time.Now(),
|
||||||
|
models.TokenTypeRegistration,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -211,6 +212,25 @@ func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error)
|
||||||
return tag.RowsAffected(), nil
|
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{} {
|
func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <-chan struct{} {
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
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")
|
logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users")
|
||||||
}
|
}
|
||||||
} else {
|
} 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():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
|
|
@ -49,6 +49,38 @@ func SendRegistrationEmail(toAddress string, toName string, username string, com
|
||||||
return nil
|
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} ]+$`)
|
var EmailRegex = regexp.MustCompile(`^[^:\p{Cc} ]+@[^:\p{Cc} ]+\.[^:\p{Cc} ]+$`)
|
||||||
|
|
||||||
func IsEmail(address string) bool {
|
func IsEmail(address string) bool {
|
||||||
|
|
|
@ -28,7 +28,6 @@ func TestUrl(t *testing.T) {
|
||||||
|
|
||||||
func TestHomepage(t *testing.T) {
|
func TestHomepage(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil)
|
AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil)
|
||||||
AssertRegexMatch(t, BuildHomepageWithRegistrationSuccess(), RegexHomepage, nil)
|
|
||||||
AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil)
|
AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil)
|
||||||
AssertSubdomain(t, BuildProjectHomepage("hero"), "hero")
|
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"})
|
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) {
|
func TestStaticPages(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
|
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
|
||||||
AssertRegexMatch(t, BuildAbout(), RegexAbout, 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"})
|
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) {
|
func TestSnippet(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
|
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
|
||||||
}
|
}
|
||||||
|
@ -134,7 +143,6 @@ func TestPodcast(t *testing.T) {
|
||||||
|
|
||||||
func TestPodcastEdit(t *testing.T) {
|
func TestPodcastEdit(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildPodcastEdit(""), RegexPodcastEdit, nil)
|
AssertRegexMatch(t, BuildPodcastEdit(""), RegexPodcastEdit, nil)
|
||||||
AssertRegexMatch(t, BuildPodcastEditSuccess(""), RegexPodcastEdit, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPodcastEpisode(t *testing.T) {
|
func TestPodcastEpisode(t *testing.T) {
|
||||||
|
@ -147,7 +155,6 @@ func TestPodcastEpisodeNew(t *testing.T) {
|
||||||
|
|
||||||
func TestPodcastEpisodeEdit(t *testing.T) {
|
func TestPodcastEpisodeEdit(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildPodcastEpisodeEdit("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"})
|
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) {
|
func TestPodcastRSS(t *testing.T) {
|
||||||
|
|
|
@ -23,10 +23,6 @@ func BuildHomepage() string {
|
||||||
return Url("/", nil)
|
return Url("/", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildHomepageWithRegistrationSuccess() string {
|
|
||||||
return Url("/", []Q{{Name: "registered", Value: "true"}})
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildProjectHomepage(projectSlug string) string {
|
func BuildProjectHomepage(projectSlug string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return ProjectUrl("/", nil, projectSlug)
|
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)
|
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()
|
defer CatchPanic()
|
||||||
return Url("/password_reset", nil)
|
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<username>[\w\ \.\,\-@\+\_]+)/(?P<token>[\d\w]+)[\/]?$`)
|
||||||
|
var RegexDoPasswordReset = regexp.MustCompile("^/password_reset/(?P<username>[^/]+)/(?P<token>[^/]+)$")
|
||||||
|
|
||||||
|
func BuildDoPasswordReset(username string, token string) string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url(fmt.Sprintf("/password_reset/%s/%s", url.PathEscape(username), token), nil)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Static Pages
|
* Static Pages
|
||||||
*/
|
*/
|
||||||
|
@ -176,9 +187,10 @@ func BuildUserProfile(username string) string {
|
||||||
return Url("/m/"+url.PathEscape(username), nil)
|
return Url("/m/"+url.PathEscape(username), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
var RegexUserSettings = regexp.MustCompile(`^/_settings$`)
|
||||||
func BuildUserSettings(username string) string {
|
|
||||||
return ""
|
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)
|
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<episodeid>[^/]+)$`)
|
var RegexPodcastEpisode = regexp.MustCompile(`^/podcast/ep/(?P<episodeid>[^/]+)$`)
|
||||||
|
|
||||||
func BuildPodcastEpisode(projectSlug string, episodeGUID string) string {
|
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)
|
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$`)
|
var RegexPodcastRSS = regexp.MustCompile(`^/podcast/podcast.xml$`)
|
||||||
|
|
||||||
func BuildPodcastRSS(projectSlug string) string {
|
func BuildPodcastRSS(projectSlug string) string {
|
||||||
|
|
|
@ -46,6 +46,13 @@ type User struct {
|
||||||
MarkedAllReadAt time.Time `db:"marked_all_read_at"`
|
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 {
|
func (u *User) IsActive() bool {
|
||||||
return u.Status == UserStatusActive
|
return u.Status == UserStatusActive
|
||||||
}
|
}
|
||||||
|
|
|
@ -126,14 +126,6 @@ func UserAvatarUrl(u *models.User, currentTheme string) string {
|
||||||
return avatar
|
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 {
|
func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return User{
|
return User{
|
||||||
|
@ -154,7 +146,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
Email: email,
|
Email: email,
|
||||||
IsStaff: u.IsStaff,
|
IsStaff: u.IsStaff,
|
||||||
|
|
||||||
Name: UserDisplayName(u),
|
Name: u.BestName(),
|
||||||
Blurb: u.Blurb,
|
Blurb: u.Blurb,
|
||||||
Signature: u.Signature,
|
Signature: u.Signature,
|
||||||
DateJoined: u.DateJoined,
|
DateJoined: u.DateJoined,
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="content-block">
|
||||||
|
<form id="password_reset_form" method="post">
|
||||||
|
<input type="hidden" name="token" value="{{ .Token }}" />
|
||||||
|
{{/*NOTE: The username field isn't `type="hidden"` because this way browser will offer to save the username along with the password */}}
|
||||||
|
<input style="position:absolute; visibility:hidden;" type="text" name="username" value="{{ .Username }}" readonly />
|
||||||
|
|
||||||
|
<h1>Hi, {{ .Username }}!</h1>
|
||||||
|
<p class="mb3 b">Please enter a new password</p>
|
||||||
|
<p class="mb2">
|
||||||
|
<label class="db b" for="password">New Password</label>
|
||||||
|
<input class="db" type="password" name="password" minlength="8" required />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mb2">
|
||||||
|
<label class="db b" for="password2">New password confirmation</label>
|
||||||
|
<input class="db" type="password" name="password2" minlength="8" required />
|
||||||
|
<span class="note db">Enter the same password as before, for verification.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input class="db mt3" type="submit" value="Reset your password" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let form = document.querySelector("#password_reset_form")
|
||||||
|
let password1 = form.querySelector("input[name=password]")
|
||||||
|
let password2 = form.querySelector("input[name=password2]")
|
||||||
|
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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
|
@ -4,12 +4,12 @@
|
||||||
<div class="content-block">
|
<div class="content-block">
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="token" value="{{ .Token }}" />
|
<input type="hidden" name="token" value="{{ .Token }}" />
|
||||||
|
{{/*NOTE: The username field isn't `type="hidden"` because this way browser will offer to save the username along with the password */}}
|
||||||
|
<input style="position:absolute; visibility:hidden;" type="text" name="username" value="{{ .Username }}" readonly />
|
||||||
|
|
||||||
<input style="visibility:hidden;" type="text" name="username" value="{{ .Username }}" required />
|
|
||||||
<h1>Hi, {{ .Username }}!</h1>
|
<h1>Hi, {{ .Username }}!</h1>
|
||||||
<p class="mb3 b">You're almost done signing up.</p>
|
<p class="mb3 b">You're almost done signing up.</p>
|
||||||
<p>To complete your registration and log in, please enter the password you used during the registration process.</p>
|
<p>To complete your registration and log in, please enter the password you used during the registration process.</p>
|
||||||
{{/*NOTE: The username field isn't `type="hidden"` because this way browser will offer to save the username along with the password */}}
|
|
||||||
<label class="db b" for="password">Password</label>
|
<label class="db b" for="password">Password</label>
|
||||||
<input class="db" type="password" name="password" minlength="8" required />
|
<input class="db" type="password" name="password" minlength="8" required />
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="content-block">
|
||||||
|
<h1>Request a password reset link</h1>
|
||||||
|
<form method="POST">
|
||||||
|
<p class="mb2">
|
||||||
|
<label class="db b" for="username">Username</label>
|
||||||
|
<input class="db w5" name="username" minlength="3" maxlength="30" type="text" required pattern="^[0-9a-zA-Z][\w-]{2,29}$" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mb2">
|
||||||
|
<label class="db b" for="email">Email</label>
|
||||||
|
<input class="db w5" name="email" type="text" required />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mt3">
|
||||||
|
<input type="submit" value="Request password reset link" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="note mt3">
|
||||||
|
Note: To avoid your being spammed with password reset links, we limit the number of requests per account every 24 hours.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="content-block">
|
||||||
|
<h4>A password reset link was sent. It'll expire in 24 hours.</h4>
|
||||||
|
<p>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 <a href="{{ .ContactUsUrl }}">contact the staff</a>.</p>
|
||||||
|
<p><small>* 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.</small></p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -43,13 +43,16 @@
|
||||||
let form = document.querySelector("#register_form")
|
let form = document.querySelector("#register_form")
|
||||||
let password1 = form.querySelector("input[name=password]")
|
let password1 = form.querySelector("input[name=password]")
|
||||||
let password2 = form.querySelector("input[name=password2]")
|
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) {
|
if (password1.value != password2.value) {
|
||||||
password2.setCustomValidity("Password doesn't match");
|
password2.setCustomValidity("Password doesn't match");
|
||||||
} else {
|
} else {
|
||||||
password2.setCustomValidity("");
|
password2.setCustomValidity("");
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
<p>Hello, {{ .Name }}</p>
|
||||||
|
<p>Someone has requested a password reset for your account.<br />
|
||||||
|
We hope it was you. If you didn't request it, we apologise. Kindly ignore this message.</p>
|
||||||
|
<p>To finish the password reset, visit: <a href="{{ .DoPasswordResetUrl }}"><b>{{ .DoPasswordResetUrl }}</b></a><br />
|
||||||
|
This link will be valid for 24 hours (until {{ absolutedate .Expiration }} UTC)</p>
|
||||||
|
<p>Thanks,<br />
|
||||||
|
The Handmade Network staff.</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<p style="font-size:small; -webkit-text-size-adjust:none ;color: #666">
|
||||||
|
You are receiving this email because someone requested a password reset for your account and supplied both your username and email address correctly.
|
||||||
|
</p>
|
|
@ -1,8 +1,6 @@
|
||||||
<p>Hello, {{ .Name }}</p>
|
<p>Hello, {{ .Name }}</p>
|
||||||
<br />
|
<p>Welcome to <a href="{{ .HomepageUrl }}"><b>Handmade Network</b></a>.<br />
|
||||||
<p>Welcome to <a href="{{ .HomepageUrl }}"><b>Handmade Network</b></a>.</p>
|
To complete the registration process, please use the following link: <a href="{{ .CompleteRegistrationUrl }}"><b>{{ .CompleteRegistrationUrl }}</b></a>.</p>
|
||||||
<p>To complete the registration process, please use the following link: <a href="{{ .CompleteRegistrationUrl }}"><b>{{ .CompleteRegistrationUrl }}</b></a>.</p>
|
|
||||||
<br />
|
|
||||||
<p>Thanks,<br />
|
<p>Thanks,<br />
|
||||||
The Handmade Network staff.</p>
|
The Handmade Network staff.</p>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
<div class="mb1">
|
||||||
{{ range . }}
|
{{ range . }}
|
||||||
<div class="content-block notice notice-{{ .Class }}">
|
<div class="content-block notice notice-{{ .Class }}">
|
||||||
{{ .Content }}
|
{{ .Content }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
|
@ -29,6 +29,13 @@ type BaseData struct {
|
||||||
MathjaxEnabled bool
|
MathjaxEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (bd *BaseData) AddImmediateNotice(class, content string) {
|
||||||
|
bd.Notices = append(bd.Notices, Notice{
|
||||||
|
Class: class,
|
||||||
|
Content: template.HTML(content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type Header struct {
|
type Header struct {
|
||||||
AdminUrl string
|
AdminUrl string
|
||||||
UserSettingsUrl string
|
UserSettingsUrl string
|
||||||
|
|
|
@ -3,7 +3,6 @@ package website
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -41,7 +40,7 @@ func LoginPage(c *RequestContext) ResponseData {
|
||||||
res.MustWriteTemplate("auth_login.html", LoginPageData{
|
res.MustWriteTemplate("auth_login.html", LoginPageData{
|
||||||
BaseData: getBaseData(c),
|
BaseData: getBaseData(c),
|
||||||
RedirectUrl: c.Req.URL.Query().Get("redirect"),
|
RedirectUrl: c.Req.URL.Query().Get("redirect"),
|
||||||
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
|
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
@ -57,29 +56,33 @@ func Login(c *RequestContext) ResponseData {
|
||||||
return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "request must contain form data"))
|
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")
|
redirect := form.Get("redirect")
|
||||||
if redirect == "" {
|
if redirect == "" {
|
||||||
redirect = "/"
|
redirect = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE LOWER(username) = LOWER($1)", username)
|
username := form.Get("username")
|
||||||
if err != nil {
|
password := form.Get("password")
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if username == "" || password == "" {
|
||||||
|
return c.Redirect(hmnurl.BuildLoginPage(redirect), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoginWithFailure := func(c *RequestContext, redirect string) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
baseData := getBaseData(c)
|
baseData := getBaseData(c)
|
||||||
baseData.Notices = []templates.Notice{{Content: "Incorrect username or password", Class: "failure"}}
|
baseData.AddImmediateNotice("failure", "Incorrect username or password")
|
||||||
res.MustWriteTemplate("auth_login.html", LoginPageData{
|
res.MustWriteTemplate("auth_login.html", LoginPageData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
RedirectUrl: redirect,
|
RedirectUrl: redirect,
|
||||||
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
|
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
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) {
|
||||||
|
return showLoginWithFailure(c, redirect)
|
||||||
} else {
|
} else {
|
||||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
|
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 {
|
if !success {
|
||||||
var res ResponseData
|
return showLoginWithFailure(c, redirect)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Status == models.UserStatusInactive {
|
if user.Status == models.UserStatusInactive {
|
||||||
|
@ -117,23 +112,13 @@ func Login(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Logout(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")
|
redir := c.Req.URL.Query().Get("redirect")
|
||||||
if redir == "" {
|
if redir == "" {
|
||||||
redir = "/"
|
redir = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
res := c.Redirect(redir, http.StatusSeeOther)
|
res := c.Redirect(redir, http.StatusSeeOther)
|
||||||
res.SetCookie(auth.DeleteSessionCookie)
|
logoutUser(c, &res)
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,6 +156,16 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
||||||
return RejectRequest(c, "Password confirmation doesn't match password")
|
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")
|
c.Perf.StartBlock("SQL", "Check for existing usernames and emails")
|
||||||
userAlreadyExists := true
|
userAlreadyExists := true
|
||||||
_, err := db.QueryInt(c.Context(), c.Conn,
|
_, 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"))
|
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")
|
c.Perf.StartBlock("SQL", "Create user and one time token")
|
||||||
tx, err := c.Conn.Begin(c.Context())
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -340,9 +325,9 @@ func EmailConfirmation(c *RequestContext) ResponseData {
|
||||||
return RejectRequest(c, "Bad validation url")
|
return RejectRequest(c, "Bad validation url")
|
||||||
}
|
}
|
||||||
|
|
||||||
validationResult := validateUsernameAndToken(c, username, token)
|
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration)
|
||||||
if !validationResult.IsValid {
|
if !validationResult.Match {
|
||||||
return validationResult.InvalidResponse
|
return makeResponseForBadRegistrationTokenValidationResult(c, validationResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
|
@ -361,9 +346,9 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
||||||
username := c.Req.Form.Get("username")
|
username := c.Req.Form.Get("username")
|
||||||
password := c.Req.Form.Get("password")
|
password := c.Req.Form.Get("password")
|
||||||
|
|
||||||
validationResult := validateUsernameAndToken(c, username, token)
|
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration)
|
||||||
if !validationResult.IsValid {
|
if !validationResult.Match {
|
||||||
return validationResult.InvalidResponse
|
return makeResponseForBadRegistrationTokenValidationResult(c, validationResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
success, err := tryLogin(c, validationResult.User, password)
|
success, err := tryLogin(c, validationResult.User, password)
|
||||||
|
@ -374,7 +359,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
baseData := getBaseData(c)
|
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.
|
// 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{
|
res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{
|
||||||
BaseData: getBaseData(c),
|
BaseData: getBaseData(c),
|
||||||
Token: token,
|
Token: token,
|
||||||
|
@ -419,7 +404,8 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
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)
|
err = loginUser(c, validationResult.User, &res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResponse(http.StatusInternalServerError, err)
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||||||
|
@ -427,41 +413,281 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
||||||
return res
|
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,
|
// NOTE(asaf): PasswordReset refers specifically to "forgot your password" flow over email,
|
||||||
// not to changing your password through the user settings page.
|
// not to changing your password through the user settings page.
|
||||||
func RequestPasswordReset(c *RequestContext) ResponseData {
|
func RequestPasswordReset(c *RequestContext) ResponseData {
|
||||||
// Verify not logged in
|
if c.CurrentUser != nil {
|
||||||
// Show form
|
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
||||||
return FourOhFour(c)
|
}
|
||||||
|
var res ResponseData
|
||||||
|
res.MustWriteTemplate("auth_password_reset.html", getBaseData(c), c.Perf)
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
||||||
// Verify not logged in
|
if c.CurrentUser != nil {
|
||||||
// Verify email input
|
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
||||||
// Potentially send password reset email if no password reset request is active
|
}
|
||||||
// Show success page in all cases
|
c.Req.ParseForm()
|
||||||
return FourOhFour(c)
|
|
||||||
|
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.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func PasswordReset(c *RequestContext) ResponseData {
|
var user *models.User
|
||||||
// Verify reset token
|
|
||||||
// If logged in
|
c.Perf.StartBlock("SQL", "Fetching user")
|
||||||
// If same user as reset request -> Mark password reset as resolved and redirect to user settings
|
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{},
|
||||||
// If other user -> Log out
|
`
|
||||||
// Show form
|
SELECT $columns
|
||||||
return FourOhFour(c)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func PasswordResetSubmit(c *RequestContext) ResponseData {
|
if user != nil {
|
||||||
// Verify not logged in
|
c.Perf.StartBlock("SQL", "Fetching existing token")
|
||||||
// Verify reset token
|
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
|
||||||
// Verify not resolved
|
`
|
||||||
// Verify inputs
|
SELECT $columns
|
||||||
// Set new password
|
FROM handmade_onetimetoken
|
||||||
// Mark resolved
|
WHERE
|
||||||
// Log in
|
token_type = $1
|
||||||
// Redirect to user settings page
|
AND owner_id = $2
|
||||||
return FourOhFour(c)
|
`,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordResetSentData struct {
|
||||||
|
templates.BaseData
|
||||||
|
ContactUsUrl string
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
type validateUserAndTokenResult struct {
|
type validateUserAndTokenResult struct {
|
||||||
User *models.User
|
User *models.User
|
||||||
OneTimeToken *models.OneTimeToken
|
OneTimeToken *models.OneTimeToken
|
||||||
IsValid bool
|
Match bool
|
||||||
InvalidResponse ResponseData
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateUsernameAndToken(c *RequestContext, username string, token string) validateUserAndTokenResult {
|
func validateUsernameAndToken(c *RequestContext, username string, token string, tokenType models.OneTimeTokenType) validateUserAndTokenResult {
|
||||||
c.Perf.StartBlock("SQL", "Check username and token")
|
c.Perf.StartBlock("SQL", "Check username and token")
|
||||||
defer c.Perf.EndBlock()
|
defer c.Perf.EndBlock()
|
||||||
type userAndTokenQuery struct {
|
type userAndTokenQuery struct {
|
||||||
|
@ -554,32 +793,26 @@ func validateUsernameAndToken(c *RequestContext, username string, token string)
|
||||||
SELECT $columns
|
SELECT $columns
|
||||||
FROM auth_user
|
FROM auth_user
|
||||||
LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id
|
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,
|
username,
|
||||||
|
tokenType,
|
||||||
)
|
)
|
||||||
var result validateUserAndTokenResult
|
var result validateUserAndTokenResult
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if !errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
result.IsValid = false
|
result.Error = oops.New(err, "failed to fetch user and token from db")
|
||||||
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.")
|
return result
|
||||||
} else {
|
|
||||||
result.IsValid = false
|
|
||||||
result.InvalidResponse = ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch token from db"))
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
if row != nil {
|
||||||
data := row.(*userAndTokenQuery)
|
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.User = &data.User
|
||||||
result.OneTimeToken = data.OneTimeToken
|
result.OneTimeToken = data.OneTimeToken
|
||||||
|
if result.OneTimeToken != nil {
|
||||||
|
result.Match = (result.OneTimeToken.Content == token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -317,10 +317,6 @@ func Index(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(c)
|
baseData := getBaseData(c)
|
||||||
baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more?
|
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
|
var res ResponseData
|
||||||
err = res.WriteTemplate("landing.html", LandingTemplateData{
|
err = res.WriteTemplate("landing.html", LandingTemplateData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
|
|
|
@ -99,11 +99,6 @@ func PodcastEdit(c *RequestContext) ResponseData {
|
||||||
Podcast: podcast,
|
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
|
var res ResponseData
|
||||||
err = res.WriteTemplate("podcast_edit.html", podcastEditData, c.Perf)
|
err = res.WriteTemplate("podcast_edit.html", podcastEditData, c.Perf)
|
||||||
if err != nil {
|
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 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 {
|
type PodcastEpisodeData struct {
|
||||||
|
@ -388,11 +385,6 @@ func PodcastEpisodeEdit(c *RequestContext) ResponseData {
|
||||||
EpisodeFiles: episodeFiles,
|
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
|
var res ResponseData
|
||||||
err = res.WriteTemplate("podcast_episode_edit.html", podcastEpisodeEditData, c.Perf)
|
err = res.WriteTemplate("podcast_episode_edit.html", podcastEpisodeEditData, c.Perf)
|
||||||
if err != nil {
|
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) {
|
func GetEpisodeFiles(projectSlug string) ([]string, error) {
|
||||||
|
|
|
@ -3,7 +3,6 @@ package website
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -380,39 +379,48 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.Flags == 1 {
|
if project.Flags == 1 {
|
||||||
hiddenNotice := templates.Notice{
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
Class: "hidden",
|
"hidden",
|
||||||
Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
||||||
}
|
)
|
||||||
projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, hiddenNotice)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.Lifecycle != models.ProjectLifecycleActive {
|
if project.Lifecycle != models.ProjectLifecycleActive {
|
||||||
var lifecycleNotice templates.Notice
|
|
||||||
switch project.Lifecycle {
|
switch project.Lifecycle {
|
||||||
case models.ProjectLifecycleUnapproved:
|
case models.ProjectLifecycleUnapproved:
|
||||||
lifecycleNotice.Class = "unapproved"
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
lifecycleNotice.Content = template.HTML(fmt.Sprintf(
|
"unapproved",
|
||||||
|
fmt.Sprintf(
|
||||||
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
|
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
|
||||||
hmnurl.BuildProjectEdit(project.Slug, "submit"),
|
hmnurl.BuildProjectEdit(project.Slug, "submit"),
|
||||||
))
|
),
|
||||||
|
)
|
||||||
case models.ProjectLifecycleApprovalRequired:
|
case models.ProjectLifecycleApprovalRequired:
|
||||||
lifecycleNotice.Class = "unapproved"
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval. It is only visible to owners and site admins.")
|
"unapproved",
|
||||||
|
"NOTICE: This project is awaiting approval. It is only visible to owners and site admins.",
|
||||||
|
)
|
||||||
case models.ProjectLifecycleHiatus:
|
case models.ProjectLifecycleHiatus:
|
||||||
lifecycleNotice.Class = "hiatus"
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
lifecycleNotice.Content = template.HTML("NOTICE: This project is on hiatus and may not update for a while.")
|
"hiatus",
|
||||||
|
"NOTICE: This project is on hiatus and may not update for a while.",
|
||||||
|
)
|
||||||
case models.ProjectLifecycleDead:
|
case models.ProjectLifecycleDead:
|
||||||
lifecycleNotice.Class = "dead"
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
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.")
|
"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:
|
case models.ProjectLifecycleLTSRequired:
|
||||||
lifecycleNotice.Class = "lts-reqd"
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval for maintenance-mode status.")
|
"lts-reqd",
|
||||||
|
"NOTICE: This project is awaiting approval for maintenance-mode status.",
|
||||||
|
)
|
||||||
case models.ProjectLifecycleLTS:
|
case models.ProjectLifecycleLTS:
|
||||||
lifecycleNotice.Class = "lts"
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.")
|
"lts",
|
||||||
|
"NOTICE: This project has reached a state of completion.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, lifecycleNotice)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, screenshot := range screenshotQueryResult.ToSlice() {
|
for _, screenshot := range screenshotQueryResult.ToSlice() {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -244,6 +245,7 @@ type ResponseData struct {
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Body *bytes.Buffer
|
Body *bytes.Buffer
|
||||||
Errors []error
|
Errors []error
|
||||||
|
FutureNotices []templates.Notice
|
||||||
|
|
||||||
header http.Header
|
header http.Header
|
||||||
}
|
}
|
||||||
|
@ -274,6 +276,10 @@ func (rd *ResponseData) SetCookie(cookie *http.Cookie) {
|
||||||
rd.Header().Add("Set-Cookie", cookie.String())
|
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 {
|
func (rd *ResponseData) WriteTemplate(name string, data interface{}, rp *perf.RequestPerf) error {
|
||||||
if rp != nil {
|
if rp != nil {
|
||||||
rp.StartBlock("TEMPLATE", name)
|
rp.StartBlock("TEMPLATE", name)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -50,6 +51,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
||||||
|
|
||||||
defer LogContextErrors(c, &res)
|
defer LogContextErrors(c, &res)
|
||||||
|
|
||||||
|
defer storeNoticesInCookie(c, &res)
|
||||||
|
|
||||||
ok, errRes := LoadCommonWebsiteData(c)
|
ok, errRes := LoadCommonWebsiteData(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errRes
|
return errRes
|
||||||
|
@ -69,6 +72,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
||||||
|
|
||||||
defer LogContextErrors(c, &res)
|
defer LogContextErrors(c, &res)
|
||||||
|
|
||||||
|
defer storeNoticesInCookie(c, &res)
|
||||||
|
|
||||||
ok, errRes := LoadCommonWebsiteData(c)
|
ok, errRes := LoadCommonWebsiteData(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errRes
|
return errRes
|
||||||
|
@ -153,6 +158,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
||||||
mainRoutes.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
mainRoutes.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
||||||
mainRoutes.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
|
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.RegexFeed, Feed)
|
||||||
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
|
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
|
||||||
mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
|
mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
|
||||||
|
@ -225,6 +237,8 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
||||||
templateSession = &s
|
templateSession = &s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notices := getNoticesFromCookie(c)
|
||||||
|
|
||||||
return templates.BaseData{
|
return templates.BaseData{
|
||||||
Theme: c.Theme,
|
Theme: c.Theme,
|
||||||
|
|
||||||
|
@ -235,11 +249,12 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
||||||
Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
|
Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
|
||||||
User: templateUser,
|
User: templateUser,
|
||||||
Session: templateSession,
|
Session: templateSession,
|
||||||
|
Notices: notices,
|
||||||
|
|
||||||
IsProjectPage: !c.CurrentProject.IsHMN(),
|
IsProjectPage: !c.CurrentProject.IsHMN(),
|
||||||
Header: templates.Header{
|
Header: templates.Header{
|
||||||
AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||||
UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
UserSettingsUrl: hmnurl.BuildUserSettings(""),
|
||||||
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
|
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
|
||||||
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
|
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
|
||||||
RegisterUrl: hmnurl.BuildRegister(),
|
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")
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -65,7 +65,7 @@ func PostToTimelineItem(lineageBuilder *models.SubforumLineageBuilder, post *mod
|
||||||
Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
|
Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
|
||||||
|
|
||||||
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
||||||
OwnerName: templates.UserDisplayName(owner),
|
OwnerName: owner.BestName(),
|
||||||
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
||||||
Description: "", // NOTE(asaf): No description for posts
|
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),
|
Url: hmnurl.BuildSnippet(snippet.ID),
|
||||||
|
|
||||||
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
||||||
OwnerName: templates.UserDisplayName(owner),
|
OwnerName: owner.BestName(),
|
||||||
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
||||||
Description: template.HTML(snippet.DescriptionHtml),
|
Description: template.HTML(snippet.DescriptionHtml),
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue