Password reset and Notices

This commit is contained in:
Asaf Gartner 2021-08-17 08:18:04 +03:00
parent 4c84bd2860
commit a32f71f862
23 changed files with 698 additions and 196 deletions

View File

@ -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()

View File

@ -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

View File

@ -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 {

View File

@ -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) {

View File

@ -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 {

View File

@ -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
} }

View File

@ -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,

View File

@ -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 }}

View File

@ -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 />

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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)
} }
} }

View File

@ -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,

View File

@ -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) {

View File

@ -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() {

View File

@ -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)

View File

@ -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(&noticesCookie)
} 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(&noticesCookie)
}
}
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
}

View File

@ -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),