Password reset and Notices
This commit is contained in:
parent
4c84bd2860
commit
a32f71f862
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/auth"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
|
@ -114,6 +115,8 @@ func init() {
|
|||
switch emailType {
|
||||
case "registration":
|
||||
err = email.SendRegistrationEmail(toAddress, toName, "test_user", "test_token", p)
|
||||
case "passwordreset":
|
||||
err = email.SendPasswordReset(toAddress, toName, "test_user", "test_token", time.Now().Add(time.Hour*24), p)
|
||||
default:
|
||||
fmt.Printf("You must provide a valid email type\n\n")
|
||||
cmd.Usage()
|
||||
|
|
|
@ -198,10 +198,11 @@ func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error)
|
|||
DELETE FROM auth_user
|
||||
WHERE
|
||||
status = $1 AND
|
||||
(SELECT COUNT(*) as ct FROM handmade_onetimetoken AS ott WHERE ott.owner_id = auth_user.id AND ott.expires < $2) > 0;
|
||||
(SELECT COUNT(*) as ct FROM handmade_onetimetoken AS ott WHERE ott.owner_id = auth_user.id AND ott.expires < $2 AND ott.token_type = $3) > 0;
|
||||
`,
|
||||
models.UserStatusInactive,
|
||||
time.Now(),
|
||||
models.TokenTypeRegistration,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
@ -211,6 +212,25 @@ func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error)
|
|||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func DeleteExpiredPasswordResets(ctx context.Context, conn *pgxpool.Pool) (int64, error) {
|
||||
tag, err := conn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM handmade_onetimetoken
|
||||
WHERE
|
||||
token_type = $1
|
||||
AND expires < $2
|
||||
`,
|
||||
models.TokenTypePasswordReset,
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, oops.New(err, "failed to delete expired password resets")
|
||||
}
|
||||
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
|
@ -226,7 +246,16 @@ func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <-
|
|||
logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users")
|
||||
}
|
||||
} else {
|
||||
logging.Error().Err(err).Msg("Failed to delete expired sessions")
|
||||
logging.Error().Err(err).Msg("Failed to delete inactive users")
|
||||
}
|
||||
|
||||
n, err = DeleteExpiredPasswordResets(ctx, conn)
|
||||
if err == nil {
|
||||
if n > 0 {
|
||||
logging.Info().Int64("num deleted password resets", n).Msg("Deleted expired password resets")
|
||||
}
|
||||
} else {
|
||||
logging.Error().Err(err).Msg("Failed to delete expired password resets")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
|
|
@ -49,6 +49,38 @@ func SendRegistrationEmail(toAddress string, toName string, username string, com
|
|||
return nil
|
||||
}
|
||||
|
||||
type PasswordResetEmailData struct {
|
||||
Name string
|
||||
DoPasswordResetUrl string
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
func SendPasswordReset(toAddress string, toName string, username string, resetToken string, expiration time.Time, perf *perf.RequestPerf) error {
|
||||
perf.StartBlock("EMAIL", "Password reset email")
|
||||
|
||||
perf.StartBlock("EMAIL", "Rendering template")
|
||||
contents, err := renderTemplate("email_password_reset.html", PasswordResetEmailData{
|
||||
Name: toName,
|
||||
DoPasswordResetUrl: hmnurl.BuildDoPasswordReset(username, resetToken),
|
||||
Expiration: expiration,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
perf.EndBlock()
|
||||
|
||||
perf.StartBlock("EMAIL", "Sending email")
|
||||
err = sendMail(toAddress, toName, "[handmade.network] Your password reset request", contents)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to send email")
|
||||
}
|
||||
perf.EndBlock()
|
||||
|
||||
perf.EndBlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var EmailRegex = regexp.MustCompile(`^[^:\p{Cc} ]+@[^:\p{Cc} ]+\.[^:\p{Cc} ]+$`)
|
||||
|
||||
func IsEmail(address string) bool {
|
||||
|
|
|
@ -28,7 +28,6 @@ func TestUrl(t *testing.T) {
|
|||
|
||||
func TestHomepage(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil)
|
||||
AssertRegexMatch(t, BuildHomepageWithRegistrationSuccess(), RegexHomepage, nil)
|
||||
AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil)
|
||||
AssertSubdomain(t, BuildProjectHomepage("hero"), "hero")
|
||||
}
|
||||
|
@ -79,6 +78,12 @@ func TestEmailConfirmation(t *testing.T) {
|
|||
AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token"), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"})
|
||||
}
|
||||
|
||||
func TestPasswordReset(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildRequestPasswordReset(), RegexRequestPasswordReset, nil)
|
||||
AssertRegexMatch(t, BuildPasswordResetSent(), RegexPasswordResetSent, nil)
|
||||
AssertRegexMatch(t, BuildDoPasswordReset("user", "token"), RegexDoPasswordReset, map[string]string{"username": "user", "token": "token"})
|
||||
}
|
||||
|
||||
func TestStaticPages(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
|
||||
AssertRegexMatch(t, BuildAbout(), RegexAbout, nil)
|
||||
|
@ -93,6 +98,10 @@ func TestUserProfile(t *testing.T) {
|
|||
AssertRegexMatch(t, BuildUserProfile("test"), RegexUserProfile, map[string]string{"username": "test"})
|
||||
}
|
||||
|
||||
func TestUserSettings(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildUserSettings("test"), RegexUserSettings, nil)
|
||||
}
|
||||
|
||||
func TestSnippet(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
|
||||
}
|
||||
|
@ -134,7 +143,6 @@ func TestPodcast(t *testing.T) {
|
|||
|
||||
func TestPodcastEdit(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPodcastEdit(""), RegexPodcastEdit, nil)
|
||||
AssertRegexMatch(t, BuildPodcastEditSuccess(""), RegexPodcastEdit, nil)
|
||||
}
|
||||
|
||||
func TestPodcastEpisode(t *testing.T) {
|
||||
|
@ -147,7 +155,6 @@ func TestPodcastEpisodeNew(t *testing.T) {
|
|||
|
||||
func TestPodcastEpisodeEdit(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPodcastEpisodeEdit("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"})
|
||||
AssertRegexMatch(t, BuildPodcastEpisodeEditSuccess("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"})
|
||||
}
|
||||
|
||||
func TestPodcastRSS(t *testing.T) {
|
||||
|
|
|
@ -23,10 +23,6 @@ func BuildHomepage() string {
|
|||
return Url("/", nil)
|
||||
}
|
||||
|
||||
func BuildHomepageWithRegistrationSuccess() string {
|
||||
return Url("/", []Q{{Name: "registered", Value: "true"}})
|
||||
}
|
||||
|
||||
func BuildProjectHomepage(projectSlug string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl("/", nil, projectSlug)
|
||||
|
@ -102,13 +98,28 @@ func BuildEmailConfirmation(username, token string) string {
|
|||
return Url(fmt.Sprintf("/email_confirmation/%s/%s", url.PathEscape(username), token), nil)
|
||||
}
|
||||
|
||||
var RegexPasswordResetRequest = regexp.MustCompile("^/password_reset$")
|
||||
var RegexRequestPasswordReset = regexp.MustCompile("^/password_reset$")
|
||||
|
||||
func BuildPasswordResetRequest() string {
|
||||
func BuildRequestPasswordReset() string {
|
||||
defer CatchPanic()
|
||||
return Url("/password_reset", nil)
|
||||
}
|
||||
|
||||
var RegexPasswordResetSent = regexp.MustCompile("^/password_reset/sent$")
|
||||
|
||||
func BuildPasswordResetSent() string {
|
||||
defer CatchPanic()
|
||||
return Url("/password_reset/sent", nil)
|
||||
}
|
||||
|
||||
var RegexOldDoPasswordReset = regexp.MustCompile(`^_password_reset/(?P<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
|
||||
*/
|
||||
|
@ -176,9 +187,10 @@ func BuildUserProfile(username string) string {
|
|||
return Url("/m/"+url.PathEscape(username), nil)
|
||||
}
|
||||
|
||||
// TODO
|
||||
func BuildUserSettings(username string) string {
|
||||
return ""
|
||||
var RegexUserSettings = regexp.MustCompile(`^/_settings$`)
|
||||
|
||||
func BuildUserSettings(section string) string {
|
||||
return ProjectUrlWithFragment("/_settings", nil, "", section)
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -291,11 +303,6 @@ func BuildPodcastEdit(projectSlug string) string {
|
|||
return ProjectUrl("/podcast/edit", nil, projectSlug)
|
||||
}
|
||||
|
||||
func BuildPodcastEditSuccess(projectSlug string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl("/podcast/edit", []Q{Q{"success", "true"}}, projectSlug)
|
||||
}
|
||||
|
||||
var RegexPodcastEpisode = regexp.MustCompile(`^/podcast/ep/(?P<episodeid>[^/]+)$`)
|
||||
|
||||
func BuildPodcastEpisode(projectSlug string, episodeGUID string) string {
|
||||
|
@ -317,11 +324,6 @@ func BuildPodcastEpisodeEdit(projectSlug string, episodeGUID string) string {
|
|||
return ProjectUrl(fmt.Sprintf("/podcast/ep/%s/edit", episodeGUID), nil, projectSlug)
|
||||
}
|
||||
|
||||
func BuildPodcastEpisodeEditSuccess(projectSlug string, episodeGUID string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl(fmt.Sprintf("/podcast/ep/%s/edit", episodeGUID), []Q{Q{"success", "true"}}, projectSlug)
|
||||
}
|
||||
|
||||
var RegexPodcastRSS = regexp.MustCompile(`^/podcast/podcast.xml$`)
|
||||
|
||||
func BuildPodcastRSS(projectSlug string) string {
|
||||
|
|
|
@ -46,6 +46,13 @@ type User struct {
|
|||
MarkedAllReadAt time.Time `db:"marked_all_read_at"`
|
||||
}
|
||||
|
||||
func (u *User) BestName() string {
|
||||
if u.Name != "" {
|
||||
return u.Name
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func (u *User) IsActive() bool {
|
||||
return u.Status == UserStatusActive
|
||||
}
|
||||
|
|
|
@ -126,14 +126,6 @@ func UserAvatarUrl(u *models.User, currentTheme string) string {
|
|||
return avatar
|
||||
}
|
||||
|
||||
func UserDisplayName(u *models.User) string {
|
||||
name := u.Name
|
||||
if u.Name == "" {
|
||||
name = u.Username
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func UserToTemplate(u *models.User, currentTheme string) User {
|
||||
if u == nil {
|
||||
return User{
|
||||
|
@ -154,7 +146,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
|||
Email: email,
|
||||
IsStaff: u.IsStaff,
|
||||
|
||||
Name: UserDisplayName(u),
|
||||
Name: u.BestName(),
|
||||
Blurb: u.Blurb,
|
||||
Signature: u.Signature,
|
||||
DateJoined: u.DateJoined,
|
||||
|
|
|
@ -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">
|
||||
<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 />
|
||||
|
||||
<input style="visibility:hidden;" type="text" name="username" value="{{ .Username }}" required />
|
||||
<h1>Hi, {{ .Username }}!</h1>
|
||||
<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>
|
||||
{{/*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>
|
||||
<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 password1 = form.querySelector("input[name=password]")
|
||||
let password2 = form.querySelector("input[name=password2]")
|
||||
password2.addEventListener("input", function(ev) {
|
||||
password1.addEventListener("input", validatePasswordConfirmation);
|
||||
password2.addEventListener("input", validatePasswordConfirmation);
|
||||
|
||||
function validatePasswordConfirmation(ev) {
|
||||
if (password1.value != password2.value) {
|
||||
password2.setCustomValidity("Password doesn't match");
|
||||
} else {
|
||||
password2.setCustomValidity("");
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{{ 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>
|
||||
<br />
|
||||
<p>Welcome to <a href="{{ .HomepageUrl }}"><b>Handmade Network</b></a>.</p>
|
||||
<p>To complete the registration process, please use the following link: <a href="{{ .CompleteRegistrationUrl }}"><b>{{ .CompleteRegistrationUrl }}</b></a>.</p>
|
||||
<br />
|
||||
<p>Welcome to <a href="{{ .HomepageUrl }}"><b>Handmade Network</b></a>.<br />
|
||||
To complete the registration process, please use the following link: <a href="{{ .CompleteRegistrationUrl }}"><b>{{ .CompleteRegistrationUrl }}</b></a>.</p>
|
||||
<p>Thanks,<br />
|
||||
The Handmade Network staff.</p>
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<div class="mb1">
|
||||
{{ range . }}
|
||||
<div class="content-block notice notice-{{ .Class }}">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
|
|
@ -29,6 +29,13 @@ type BaseData struct {
|
|||
MathjaxEnabled bool
|
||||
}
|
||||
|
||||
func (bd *BaseData) AddImmediateNotice(class, content string) {
|
||||
bd.Notices = append(bd.Notices, Notice{
|
||||
Class: class,
|
||||
Content: template.HTML(content),
|
||||
})
|
||||
}
|
||||
|
||||
type Header struct {
|
||||
AdminUrl string
|
||||
UserSettingsUrl string
|
||||
|
|
|
@ -3,7 +3,6 @@ package website
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
@ -41,7 +40,7 @@ func LoginPage(c *RequestContext) ResponseData {
|
|||
res.MustWriteTemplate("auth_login.html", LoginPageData{
|
||||
BaseData: getBaseData(c),
|
||||
RedirectUrl: c.Req.URL.Query().Get("redirect"),
|
||||
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
|
||||
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
|
||||
}, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
@ -57,29 +56,33 @@ func Login(c *RequestContext) ResponseData {
|
|||
return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "request must contain form data"))
|
||||
}
|
||||
|
||||
username := form.Get("username")
|
||||
password := form.Get("password")
|
||||
if username == "" || password == "" {
|
||||
return RejectRequest(c, "You must provide both a username and password")
|
||||
}
|
||||
|
||||
redirect := form.Get("redirect")
|
||||
if redirect == "" {
|
||||
redirect = "/"
|
||||
}
|
||||
|
||||
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) {
|
||||
username := form.Get("username")
|
||||
password := form.Get("password")
|
||||
if username == "" || password == "" {
|
||||
return c.Redirect(hmnurl.BuildLoginPage(redirect), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
showLoginWithFailure := func(c *RequestContext, redirect string) ResponseData {
|
||||
var res ResponseData
|
||||
baseData := getBaseData(c)
|
||||
baseData.Notices = []templates.Notice{{Content: "Incorrect username or password", Class: "failure"}}
|
||||
baseData.AddImmediateNotice("failure", "Incorrect username or password")
|
||||
res.MustWriteTemplate("auth_login.html", LoginPageData{
|
||||
BaseData: baseData,
|
||||
RedirectUrl: redirect,
|
||||
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
|
||||
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
|
||||
}, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE LOWER(username) = LOWER($1)", username)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||
return showLoginWithFailure(c, redirect)
|
||||
} else {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
|
||||
}
|
||||
|
@ -93,15 +96,7 @@ func Login(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if !success {
|
||||
var res ResponseData
|
||||
baseData := getBaseData(c)
|
||||
baseData.Notices = []templates.Notice{{Content: "Incorrect username or password", Class: "failure"}}
|
||||
res.MustWriteTemplate("auth_login.html", LoginPageData{
|
||||
BaseData: baseData,
|
||||
RedirectUrl: redirect,
|
||||
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
|
||||
}, c.Perf)
|
||||
return res
|
||||
return showLoginWithFailure(c, redirect)
|
||||
}
|
||||
|
||||
if user.Status == models.UserStatusInactive {
|
||||
|
@ -117,23 +112,13 @@ func Login(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func Logout(c *RequestContext) ResponseData {
|
||||
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
||||
if err == nil {
|
||||
// clear the session from the db immediately, no expiration
|
||||
err := auth.DeleteSession(c.Context(), c.Conn, sessionCookie.Value)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to delete session on logout")
|
||||
}
|
||||
}
|
||||
|
||||
redir := c.Req.URL.Query().Get("redirect")
|
||||
if redir == "" {
|
||||
redir = "/"
|
||||
}
|
||||
|
||||
res := c.Redirect(redir, http.StatusSeeOther)
|
||||
res.SetCookie(auth.DeleteSessionCookie)
|
||||
|
||||
logoutUser(c, &res)
|
||||
return res
|
||||
}
|
||||
|
||||
|
@ -171,6 +156,16 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
return RejectRequest(c, "Password confirmation doesn't match password")
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Check blacklist")
|
||||
// TODO(asaf): Check email against blacklist
|
||||
blacklisted := false
|
||||
if blacklisted {
|
||||
// NOTE(asaf): Silent rejection so we don't allow attackers to harvest emails.
|
||||
time.Sleep(time.Second * 3) // NOTE(asaf): Pretend to send email
|
||||
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Check for existing usernames and emails")
|
||||
userAlreadyExists := true
|
||||
_, err := db.QueryInt(c.Context(), c.Conn,
|
||||
|
@ -222,16 +217,6 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Check blacklist")
|
||||
// TODO(asaf): Check email against blacklist
|
||||
blacklisted := false
|
||||
if blacklisted {
|
||||
// NOTE(asaf): Silent rejection so we don't allow attackers to harvest emails.
|
||||
time.Sleep(time.Second * 3) // NOTE(asaf): Pretend to send email
|
||||
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Create user and one time token")
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
if err != nil {
|
||||
|
@ -340,9 +325,9 @@ func EmailConfirmation(c *RequestContext) ResponseData {
|
|||
return RejectRequest(c, "Bad validation url")
|
||||
}
|
||||
|
||||
validationResult := validateUsernameAndToken(c, username, token)
|
||||
if !validationResult.IsValid {
|
||||
return validationResult.InvalidResponse
|
||||
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration)
|
||||
if !validationResult.Match {
|
||||
return makeResponseForBadRegistrationTokenValidationResult(c, validationResult)
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
|
@ -361,9 +346,9 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
username := c.Req.Form.Get("username")
|
||||
password := c.Req.Form.Get("password")
|
||||
|
||||
validationResult := validateUsernameAndToken(c, username, token)
|
||||
if !validationResult.IsValid {
|
||||
return validationResult.InvalidResponse
|
||||
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration)
|
||||
if !validationResult.Match {
|
||||
return makeResponseForBadRegistrationTokenValidationResult(c, validationResult)
|
||||
}
|
||||
|
||||
success, err := tryLogin(c, validationResult.User, password)
|
||||
|
@ -374,7 +359,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
var res ResponseData
|
||||
baseData := getBaseData(c)
|
||||
// NOTE(asaf): We can report that the password is incorrect, because an attacker wouldn't have a valid token to begin with.
|
||||
baseData.Notices = []templates.Notice{{Content: template.HTML("Incorrect password. Please try again."), Class: "failure"}}
|
||||
baseData.AddImmediateNotice("failure", "Incorrect password. Please try again.")
|
||||
res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{
|
||||
BaseData: getBaseData(c),
|
||||
Token: token,
|
||||
|
@ -419,7 +404,8 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
res := c.Redirect(hmnurl.BuildHomepageWithRegistrationSuccess(), http.StatusSeeOther)
|
||||
res := c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
||||
res.AddFutureNotice("success", "You've completed your registration successfully!")
|
||||
err = loginUser(c, validationResult.User, &res)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
|
@ -427,41 +413,281 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
return res
|
||||
}
|
||||
|
||||
// NOTE(asaf): Only call this when validationResult.Match is false.
|
||||
func makeResponseForBadRegistrationTokenValidationResult(c *RequestContext, validationResult validateUserAndTokenResult) ResponseData {
|
||||
if validationResult.User == nil {
|
||||
return RejectRequest(c, "You haven't validated your email in time and your user was deleted. You may try registering again with the same username.")
|
||||
}
|
||||
|
||||
if validationResult.OneTimeToken == nil {
|
||||
// NOTE(asaf): The user exists, but the validation token doesn't.
|
||||
// That means the user already validated their email and can just log in normally.
|
||||
return c.Redirect(hmnurl.BuildLoginPage(""), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
return RejectRequest(c, "Bad token. If you are having problems registering or logging in, please contact the staff.")
|
||||
}
|
||||
|
||||
// NOTE(asaf): PasswordReset refers specifically to "forgot your password" flow over email,
|
||||
// not to changing your password through the user settings page.
|
||||
func RequestPasswordReset(c *RequestContext) ResponseData {
|
||||
// Verify not logged in
|
||||
// Show form
|
||||
return FourOhFour(c)
|
||||
if c.CurrentUser != nil {
|
||||
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
||||
}
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("auth_password_reset.html", getBaseData(c), c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
||||
// Verify not logged in
|
||||
// Verify email input
|
||||
// Potentially send password reset email if no password reset request is active
|
||||
// Show success page in all cases
|
||||
return FourOhFour(c)
|
||||
if c.CurrentUser != nil {
|
||||
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
||||
}
|
||||
c.Req.ParseForm()
|
||||
|
||||
username := strings.TrimSpace(c.Req.Form.Get("username"))
|
||||
emailAddress := strings.TrimSpace(c.Req.Form.Get("email"))
|
||||
|
||||
if username == "" && emailAddress == "" {
|
||||
return RejectRequest(c, "You must provide a username and an email address.")
|
||||
}
|
||||
|
||||
func PasswordReset(c *RequestContext) ResponseData {
|
||||
// Verify reset token
|
||||
// If logged in
|
||||
// If same user as reset request -> Mark password reset as resolved and redirect to user settings
|
||||
// If other user -> Log out
|
||||
// Show form
|
||||
return FourOhFour(c)
|
||||
var user *models.User
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching user")
|
||||
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM auth_user
|
||||
WHERE
|
||||
LOWER(username) = LOWER($1)
|
||||
AND LOWER(email) = LOWER($2)
|
||||
`,
|
||||
username,
|
||||
emailAddress,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrNoMatchingRows) {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
|
||||
}
|
||||
}
|
||||
if userRow != nil {
|
||||
user = userRow.(*models.User)
|
||||
}
|
||||
|
||||
func PasswordResetSubmit(c *RequestContext) ResponseData {
|
||||
// Verify not logged in
|
||||
// Verify reset token
|
||||
// Verify not resolved
|
||||
// Verify inputs
|
||||
// Set new password
|
||||
// Mark resolved
|
||||
// Log in
|
||||
// Redirect to user settings page
|
||||
return FourOhFour(c)
|
||||
if user != nil {
|
||||
c.Perf.StartBlock("SQL", "Fetching existing token")
|
||||
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_onetimetoken
|
||||
WHERE
|
||||
token_type = $1
|
||||
AND owner_id = $2
|
||||
`,
|
||||
models.TokenTypePasswordReset,
|
||||
user.ID,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrNoMatchingRows) {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch onetimetoken for user"))
|
||||
}
|
||||
}
|
||||
var resetToken *models.OneTimeToken
|
||||
if tokenRow != nil {
|
||||
resetToken = tokenRow.(*models.OneTimeToken)
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
if resetToken != nil {
|
||||
if resetToken.Expires.Before(now.Add(time.Minute * 30)) { // NOTE(asaf): Expired or about to expire
|
||||
c.Perf.StartBlock("SQL", "Deleting expired token")
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
`
|
||||
DELETE FROM handmade_onetimetoken
|
||||
WHERE id = $1
|
||||
`,
|
||||
resetToken.ID,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete onetimetoken"))
|
||||
}
|
||||
resetToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
if resetToken == nil {
|
||||
c.Perf.StartBlock("SQL", "Creating new token")
|
||||
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
|
||||
`
|
||||
INSERT INTO handmade_onetimetoken (token_type, created, expires, token_content, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING $columns
|
||||
`,
|
||||
models.TokenTypePasswordReset,
|
||||
now,
|
||||
now.Add(time.Hour*24),
|
||||
models.GenerateToken(),
|
||||
user.ID,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create onetimetoken"))
|
||||
}
|
||||
resetToken = tokenRow.(*models.OneTimeToken)
|
||||
|
||||
err = email.SendPasswordReset(user.Email, user.BestName(), user.Username, resetToken.Content, resetToken.Expires, c.Perf)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to send email"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.Redirect(hmnurl.BuildPasswordResetSent(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -535,14 +761,27 @@ func loginUser(c *RequestContext, user *models.User, responseData *ResponseData)
|
|||
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 {
|
||||
User *models.User
|
||||
OneTimeToken *models.OneTimeToken
|
||||
IsValid bool
|
||||
InvalidResponse ResponseData
|
||||
Match bool
|
||||
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")
|
||||
defer c.Perf.EndBlock()
|
||||
type userAndTokenQuery struct {
|
||||
|
@ -554,32 +793,26 @@ func validateUsernameAndToken(c *RequestContext, username string, token string)
|
|||
SELECT $columns
|
||||
FROM auth_user
|
||||
LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id
|
||||
WHERE LOWER(auth_user.username) = LOWER($1)
|
||||
WHERE
|
||||
LOWER(auth_user.username) = LOWER($1)
|
||||
AND onetimetoken.token_type = $2
|
||||
`,
|
||||
username,
|
||||
tokenType,
|
||||
)
|
||||
var result validateUserAndTokenResult
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||
result.IsValid = false
|
||||
result.InvalidResponse = RejectRequest(c, "You haven't validated your email in time and your user was deleted. You may try registering again with the same username.")
|
||||
} else {
|
||||
result.IsValid = false
|
||||
result.InvalidResponse = ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch token from db"))
|
||||
if !errors.Is(err, db.ErrNoMatchingRows) {
|
||||
result.Error = oops.New(err, "failed to fetch user and token from db")
|
||||
return result
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if row != nil {
|
||||
data := row.(*userAndTokenQuery)
|
||||
if data.OneTimeToken == nil {
|
||||
// NOTE(asaf): The user exists, but the validation token doesn't. That means the user already validated their email and can just log in normally.
|
||||
result.IsValid = false
|
||||
result.InvalidResponse = c.Redirect(hmnurl.BuildLoginPage(""), http.StatusSeeOther)
|
||||
} else if data.OneTimeToken.Content != token {
|
||||
result.IsValid = false
|
||||
result.InvalidResponse = RejectRequest(c, "Bad token. If you are having problems registering or logging in, please contact the staff.")
|
||||
} else {
|
||||
result.IsValid = true
|
||||
result.User = &data.User
|
||||
result.OneTimeToken = data.OneTimeToken
|
||||
if result.OneTimeToken != nil {
|
||||
result.Match = (result.OneTimeToken.Content == token)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -317,10 +317,6 @@ func Index(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(c)
|
||||
baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more?
|
||||
|
||||
if c.Req.URL.Query().Get("registered") != "" {
|
||||
baseData.Notices = []templates.Notice{{Content: "You've completed your registration successfully!", Class: "success"}}
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("landing.html", LandingTemplateData{
|
||||
BaseData: baseData,
|
||||
|
|
|
@ -99,11 +99,6 @@ func PodcastEdit(c *RequestContext) ResponseData {
|
|||
Podcast: podcast,
|
||||
}
|
||||
|
||||
success := c.URL().Query().Get("success")
|
||||
if success != "" {
|
||||
podcastEditData.BaseData.Notices = append(podcastEditData.BaseData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."})
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("podcast_edit.html", podcastEditData, c.Perf)
|
||||
if err != nil {
|
||||
|
@ -249,7 +244,9 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
|||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to commit db transaction"))
|
||||
}
|
||||
|
||||
return c.Redirect(hmnurl.BuildPodcastEditSuccess(c.CurrentProject.Slug), http.StatusSeeOther)
|
||||
res := c.Redirect(hmnurl.BuildPodcastEdit(c.CurrentProject.Slug), http.StatusSeeOther)
|
||||
res.AddFutureNotice("success", "Podcast updated successfully.")
|
||||
return res
|
||||
}
|
||||
|
||||
type PodcastEpisodeData struct {
|
||||
|
@ -388,11 +385,6 @@ func PodcastEpisodeEdit(c *RequestContext) ResponseData {
|
|||
EpisodeFiles: episodeFiles,
|
||||
}
|
||||
|
||||
success := c.URL().Query().Get("success")
|
||||
if success != "" {
|
||||
podcastEpisodeEditData.BaseData.Notices = append(podcastEpisodeEditData.BaseData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."})
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("podcast_episode_edit.html", podcastEpisodeEditData, c.Perf)
|
||||
if err != nil {
|
||||
|
@ -541,7 +533,9 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
return c.Redirect(hmnurl.BuildPodcastEpisodeEditSuccess(c.CurrentProject.Slug, guidStr), http.StatusSeeOther)
|
||||
res := c.Redirect(hmnurl.BuildPodcastEpisodeEdit(c.CurrentProject.Slug, guidStr), http.StatusSeeOther)
|
||||
res.AddFutureNotice("success", "Podcast episode updated successfully.")
|
||||
return res
|
||||
}
|
||||
|
||||
func GetEpisodeFiles(projectSlug string) ([]string, error) {
|
||||
|
|
|
@ -3,7 +3,6 @@ package website
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
|
@ -380,39 +379,48 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if project.Flags == 1 {
|
||||
hiddenNotice := templates.Notice{
|
||||
Class: "hidden",
|
||||
Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
||||
}
|
||||
projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, hiddenNotice)
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
"hidden",
|
||||
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
||||
)
|
||||
}
|
||||
|
||||
if project.Lifecycle != models.ProjectLifecycleActive {
|
||||
var lifecycleNotice templates.Notice
|
||||
switch project.Lifecycle {
|
||||
case models.ProjectLifecycleUnapproved:
|
||||
lifecycleNotice.Class = "unapproved"
|
||||
lifecycleNotice.Content = template.HTML(fmt.Sprintf(
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
"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.",
|
||||
hmnurl.BuildProjectEdit(project.Slug, "submit"),
|
||||
))
|
||||
),
|
||||
)
|
||||
case models.ProjectLifecycleApprovalRequired:
|
||||
lifecycleNotice.Class = "unapproved"
|
||||
lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval. It is only visible to owners and site admins.")
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
"unapproved",
|
||||
"NOTICE: This project is awaiting approval. It is only visible to owners and site admins.",
|
||||
)
|
||||
case models.ProjectLifecycleHiatus:
|
||||
lifecycleNotice.Class = "hiatus"
|
||||
lifecycleNotice.Content = template.HTML("NOTICE: This project is on hiatus and may not update for a while.")
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
"hiatus",
|
||||
"NOTICE: This project is on hiatus and may not update for a while.",
|
||||
)
|
||||
case models.ProjectLifecycleDead:
|
||||
lifecycleNotice.Class = "dead"
|
||||
lifecycleNotice.Content = template.HTML("NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.")
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
"dead",
|
||||
"NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.",
|
||||
)
|
||||
case models.ProjectLifecycleLTSRequired:
|
||||
lifecycleNotice.Class = "lts-reqd"
|
||||
lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval for maintenance-mode status.")
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
"lts-reqd",
|
||||
"NOTICE: This project is awaiting approval for maintenance-mode status.",
|
||||
)
|
||||
case models.ProjectLifecycleLTS:
|
||||
lifecycleNotice.Class = "lts"
|
||||
lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.")
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
"lts",
|
||||
"NOTICE: This project has reached a state of completion.",
|
||||
)
|
||||
}
|
||||
projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, lifecycleNotice)
|
||||
}
|
||||
|
||||
for _, screenshot := range screenshotQueryResult.ToSlice() {
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
@ -244,6 +245,7 @@ type ResponseData struct {
|
|||
StatusCode int
|
||||
Body *bytes.Buffer
|
||||
Errors []error
|
||||
FutureNotices []templates.Notice
|
||||
|
||||
header http.Header
|
||||
}
|
||||
|
@ -274,6 +276,10 @@ func (rd *ResponseData) SetCookie(cookie *http.Cookie) {
|
|||
rd.Header().Add("Set-Cookie", cookie.String())
|
||||
}
|
||||
|
||||
func (rd *ResponseData) AddFutureNotice(class string, content string) {
|
||||
rd.FutureNotices = append(rd.FutureNotices, templates.Notice{Class: class, Content: template.HTML(content)})
|
||||
}
|
||||
|
||||
func (rd *ResponseData) WriteTemplate(name string, data interface{}, rp *perf.RequestPerf) error {
|
||||
if rp != nil {
|
||||
rp.StartBlock("TEMPLATE", name)
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
@ -50,6 +51,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
|||
|
||||
defer LogContextErrors(c, &res)
|
||||
|
||||
defer storeNoticesInCookie(c, &res)
|
||||
|
||||
ok, errRes := LoadCommonWebsiteData(c)
|
||||
if !ok {
|
||||
return errRes
|
||||
|
@ -69,6 +72,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
|||
|
||||
defer LogContextErrors(c, &res)
|
||||
|
||||
defer storeNoticesInCookie(c, &res)
|
||||
|
||||
ok, errRes := LoadCommonWebsiteData(c)
|
||||
if !ok {
|
||||
return errRes
|
||||
|
@ -153,6 +158,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
|||
mainRoutes.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
||||
mainRoutes.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexRequestPasswordReset, RequestPasswordReset)
|
||||
mainRoutes.POST(hmnurl.RegexRequestPasswordReset, RequestPasswordResetSubmit)
|
||||
mainRoutes.GET(hmnurl.RegexPasswordResetSent, PasswordResetSent)
|
||||
mainRoutes.GET(hmnurl.RegexOldDoPasswordReset, DoPasswordReset)
|
||||
mainRoutes.GET(hmnurl.RegexDoPasswordReset, DoPasswordReset)
|
||||
mainRoutes.POST(hmnurl.RegexDoPasswordReset, DoPasswordResetSubmit)
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexFeed, Feed)
|
||||
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
|
||||
mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
|
||||
|
@ -225,6 +237,8 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
|||
templateSession = &s
|
||||
}
|
||||
|
||||
notices := getNoticesFromCookie(c)
|
||||
|
||||
return templates.BaseData{
|
||||
Theme: c.Theme,
|
||||
|
||||
|
@ -235,11 +249,12 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
|||
Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
|
||||
User: templateUser,
|
||||
Session: templateSession,
|
||||
Notices: notices,
|
||||
|
||||
IsProjectPage: !c.CurrentProject.IsHMN(),
|
||||
Header: templates.Header{
|
||||
AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||
UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||
UserSettingsUrl: hmnurl.BuildUserSettings(""),
|
||||
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
|
||||
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
|
||||
RegisterUrl: hmnurl.BuildRegister(),
|
||||
|
@ -479,3 +494,83 @@ func LogContextErrors(c *RequestContext, res *ResponseData) {
|
|||
c.Logger.Error().Timestamp().Stack().Str("Requested", c.FullUrl()).Err(err).Msg("error occurred during request")
|
||||
}
|
||||
}
|
||||
|
||||
const NoticesCookieName = "hmn_notices"
|
||||
|
||||
func getNoticesFromCookie(c *RequestContext) []templates.Notice {
|
||||
cookie, err := c.Req.Cookie(NoticesCookieName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, http.ErrNoCookie) {
|
||||
c.Logger.Warn().Err(err).Msg("failed to get notices cookie")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return deserializeNoticesFromCookie(cookie.Value)
|
||||
}
|
||||
|
||||
func storeNoticesInCookie(c *RequestContext, res *ResponseData) {
|
||||
serialized := serializeNoticesForCookie(c, res.FutureNotices)
|
||||
if serialized != "" {
|
||||
noticesCookie := http.Cookie{
|
||||
Name: NoticesCookieName,
|
||||
Value: serialized,
|
||||
Path: "/",
|
||||
Domain: config.Config.Auth.CookieDomain,
|
||||
Expires: time.Now().Add(time.Minute * 5),
|
||||
Secure: config.Config.Auth.CookieSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
res.SetCookie(¬icesCookie)
|
||||
} else if !(res.StatusCode >= 300 && res.StatusCode < 400) {
|
||||
// NOTE(asaf): Don't clear on redirect
|
||||
noticesCookie := http.Cookie{
|
||||
Name: NoticesCookieName,
|
||||
Path: "/",
|
||||
Domain: config.Config.Auth.CookieDomain,
|
||||
MaxAge: -1,
|
||||
}
|
||||
res.SetCookie(¬icesCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func serializeNoticesForCookie(c *RequestContext, notices []templates.Notice) string {
|
||||
var builder strings.Builder
|
||||
maxSize := 1024 // NOTE(asaf): Make sure we don't use too much space for notices.
|
||||
size := 0
|
||||
for i, notice := range notices {
|
||||
sizeIncrease := len(notice.Class) + len(string(notice.Content)) + 1
|
||||
if i != 0 {
|
||||
sizeIncrease += 1
|
||||
}
|
||||
if size+sizeIncrease > maxSize {
|
||||
c.Logger.Warn().Interface("Notices", notices).Msg("Notices too big for cookie")
|
||||
break
|
||||
}
|
||||
|
||||
if i != 0 {
|
||||
builder.WriteString("\t")
|
||||
}
|
||||
builder.WriteString(notice.Class)
|
||||
builder.WriteString("|")
|
||||
builder.WriteString(string(notice.Content))
|
||||
|
||||
size += sizeIncrease
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func deserializeNoticesFromCookie(cookieVal string) []templates.Notice {
|
||||
var result []templates.Notice
|
||||
notices := strings.Split(cookieVal, "\t")
|
||||
for _, notice := range notices {
|
||||
parts := strings.SplitN(notice, "|", 2)
|
||||
if len(parts) == 2 {
|
||||
result = append(result, templates.Notice{
|
||||
Class: parts[0],
|
||||
Content: template.HTML(parts[1]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ func PostToTimelineItem(lineageBuilder *models.SubforumLineageBuilder, post *mod
|
|||
Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
|
||||
|
||||
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
||||
OwnerName: templates.UserDisplayName(owner),
|
||||
OwnerName: owner.BestName(),
|
||||
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
||||
Description: "", // NOTE(asaf): No description for posts
|
||||
|
||||
|
@ -122,7 +122,7 @@ func SnippetToTimelineItem(snippet *models.Snippet, asset *models.Asset, discord
|
|||
Url: hmnurl.BuildSnippet(snippet.ID),
|
||||
|
||||
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
||||
OwnerName: templates.UserDisplayName(owner),
|
||||
OwnerName: owner.BestName(),
|
||||
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
|
||||
Description: template.HTML(snippet.DescriptionHtml),
|
||||
|
||||
|
|
Loading…
Reference in New Issue