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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@ -1,5 +1,7 @@
{{ range . }}
<div class="mb1">
{{ range . }}
<div class="content-block notice notice-{{ .Class }}">
{{ .Content }}
</div>
{{ end }}
{{ end }}
</div>

View File

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

View File

@ -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.")
}
var user *models.User
c.Perf.StartBlock("SQL", "Fetching user")
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{},
`
SELECT $columns
FROM auth_user
WHERE
LOWER(username) = LOWER($1)
AND LOWER(email) = LOWER($2)
`,
username,
emailAddress,
)
c.Perf.EndBlock()
if err != nil {
if !errors.Is(err, db.ErrNoMatchingRows) {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
}
}
if userRow != nil {
user = userRow.(*models.User)
}
if user != nil {
c.Perf.StartBlock("SQL", "Fetching existing token")
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
`
SELECT $columns
FROM handmade_onetimetoken
WHERE
token_type = $1
AND owner_id = $2
`,
models.TokenTypePasswordReset,
user.ID,
)
c.Perf.EndBlock()
if err != nil {
if !errors.Is(err, db.ErrNoMatchingRows) {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch onetimetoken for user"))
}
}
var resetToken *models.OneTimeToken
if tokenRow != nil {
resetToken = tokenRow.(*models.OneTimeToken)
}
now := time.Now()
if resetToken != nil {
if resetToken.Expires.Before(now.Add(time.Minute * 30)) { // NOTE(asaf): Expired or about to expire
c.Perf.StartBlock("SQL", "Deleting expired token")
_, err = c.Conn.Exec(c.Context(),
`
DELETE FROM handmade_onetimetoken
WHERE id = $1
`,
resetToken.ID,
)
c.Perf.EndBlock()
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete onetimetoken"))
}
resetToken = nil
}
}
if resetToken == nil {
c.Perf.StartBlock("SQL", "Creating new token")
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
`
INSERT INTO handmade_onetimetoken (token_type, created, expires, token_content, owner_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING $columns
`,
models.TokenTypePasswordReset,
now,
now.Add(time.Hour*24),
models.GenerateToken(),
user.ID,
)
c.Perf.EndBlock()
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create onetimetoken"))
}
resetToken = tokenRow.(*models.OneTimeToken)
err = email.SendPasswordReset(user.Email, user.BestName(), user.Username, resetToken.Content, resetToken.Expires, c.Perf)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to send email"))
}
}
}
return c.Redirect(hmnurl.BuildPasswordResetSent(), http.StatusSeeOther)
}
func PasswordReset(c *RequestContext) ResponseData {
// Verify reset token
// If logged in
// If same user as reset request -> Mark password reset as resolved and redirect to user settings
// If other user -> Log out
// Show form
return FourOhFour(c)
type PasswordResetSentData struct {
templates.BaseData
ContactUsUrl string
}
func PasswordResetSubmit(c *RequestContext) ResponseData {
// Verify not logged in
// Verify reset token
// Verify not resolved
// Verify inputs
// Set new password
// Mark resolved
// Log in
// Redirect to user settings page
return FourOhFour(c)
func PasswordResetSent(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
var res ResponseData
res.MustWriteTemplate("auth_password_reset_sent.html", PasswordResetSentData{
BaseData: getBaseData(c),
ContactUsUrl: hmnurl.BuildContactPage(),
}, c.Perf)
return res
}
type DoPasswordResetData struct {
templates.BaseData
Username string
Token string
}
func DoPasswordReset(c *RequestContext) ResponseData {
username, hasUsername := c.PathParams["username"]
token, hasToken := c.PathParams["token"]
if !hasToken || !hasUsername {
return RejectRequest(c, "Bad validation url.")
}
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset)
if !validationResult.Match {
return RejectRequest(c, "Bad validation url.")
}
var res ResponseData
if c.CurrentUser != nil && c.CurrentUser.ID != validationResult.User.ID {
// NOTE(asaf): In the rare case that a user is logged in with user A and is trying to
// change the password for user B, log out the current user to avoid confusion.
logoutUser(c, &res)
}
res.MustWriteTemplate("auth_do_password_reset.html", DoPasswordResetData{
BaseData: getBaseData(c),
Username: username,
Token: token,
}, c.Perf)
return res
}
func DoPasswordResetSubmit(c *RequestContext) ResponseData {
c.Req.ParseForm()
token := c.Req.Form.Get("token")
username := c.Req.Form.Get("username")
password := c.Req.Form.Get("password")
password2 := c.Req.Form.Get("password2")
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset)
if !validationResult.Match {
return RejectRequest(c, "Bad validation url.")
}
if c.CurrentUser != nil && c.CurrentUser.ID != validationResult.User.ID {
return RejectRequest(c, fmt.Sprintf("Can't change password for %s. You are logged in as %s.", username, c.CurrentUser.Username))
}
if len(password) < 8 {
return RejectRequest(c, "Password too short")
}
if password != password2 {
return RejectRequest(c, "Password confirmation doesn't match password")
}
hashed, err := auth.HashPassword(password)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to encrypt password"))
}
c.Perf.StartBlock("SQL", "Update user's password and delete reset token")
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
}
defer tx.Rollback(c.Context())
tag, err := tx.Exec(c.Context(),
`
UPDATE auth_user
SET password = $1
WHERE id = $2
`,
hashed.String(),
validationResult.User.ID,
)
if err != nil || tag.RowsAffected() == 0 {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's password"))
}
if validationResult.User.Status == models.UserStatusInactive {
_, err = tx.Exec(c.Context(),
`
UPDATE auth_user
SET status = $1
WHERE id = $2
`,
models.UserStatusActive,
validationResult.User.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's status"))
}
}
_, err = tx.Exec(c.Context(),
`
DELETE FROM handmade_onetimetoken
WHERE id = $1
`,
validationResult.OneTimeToken.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete onetimetoken"))
}
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit password reset to the db"))
}
c.Perf.EndBlock()
res := c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
res.AddFutureNotice("success", "Password changed successfully.")
err = loginUser(c, validationResult.User, &res)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
}
return res
}
func tryLogin(c *RequestContext, user *models.User, password string) (bool, error) {
@ -535,14 +761,27 @@ func loginUser(c *RequestContext, user *models.User, responseData *ResponseData)
return nil
}
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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