Registration flow and email

This commit is contained in:
Asaf Gartner 2021-08-08 23:05:52 +03:00
parent 038ee7e90e
commit 660f65ba95
42 changed files with 1427 additions and 85 deletions

View File

@ -9520,3 +9520,7 @@ span.icon-rss::before {
.notice-success { .notice-success {
background-color: #43a52f; background-color: #43a52f;
background-color: var(--notice-success-color); } background-color: var(--notice-success-color); }
.notice-failure {
background-color: #b42222;
background-color: var(--notice-failure-color); }

View File

@ -248,6 +248,7 @@ will throw an error.
--notice-lts-color: #2a681d; --notice-lts-color: #2a681d;
--notice-lts-reqd-color: #876327; --notice-lts-reqd-color: #876327;
--notice-success-color: #2a681d; --notice-success-color: #2a681d;
--notice-failure-color: #7a2020;
--optionbar-border-color: #333; --optionbar-border-color: #333;
--tab-background: #181818; --tab-background: #181818;
--tab-border-color: #3f3f3f; --tab-border-color: #3f3f3f;

View File

@ -266,6 +266,7 @@ will throw an error.
--notice-lts-color: #43a52f; --notice-lts-color: #43a52f;
--notice-lts-reqd-color: #aa7d30; --notice-lts-reqd-color: #aa7d30;
--notice-success-color: #43a52f; --notice-success-color: #43a52f;
--notice-failure-color: #b42222;
--optionbar-border-color: #ccc; --optionbar-border-color: #ccc;
--tab-background: #fff; --tab-background: #fff;
--tab-border-color: #d8d8d8; --tab-border-color: #d8d8d8;

View File

@ -8,6 +8,12 @@ import (
"git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/website" "git.handmade.network/hmn/hmn/src/website"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -57,6 +63,68 @@ func init() {
fmt.Printf("Successfully updated password for '%s'\n", canonicalUsername) fmt.Printf("Successfully updated password for '%s'\n", canonicalUsername)
}, },
} }
website.WebsiteCommand.AddCommand(setPasswordCommand) website.WebsiteCommand.AddCommand(setPasswordCommand)
activateUserCommand := &cobra.Command{
Use: "activateuser [username]",
Short: "Activates a user manually",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 1 {
fmt.Printf("You must provide a username.\n\n")
cmd.Usage()
os.Exit(1)
}
username := args[0]
ctx := context.Background()
conn := db.NewConnPool(1, 1)
defer conn.Close()
res, err := conn.Exec(ctx, "UPDATE auth_user SET status = $1 WHERE LOWER(username) = LOWER($2);", models.UserStatusActive, username)
if err != nil {
panic(err)
}
if res.RowsAffected() == 0 {
fmt.Printf("User not found.\n\n")
}
fmt.Printf("User has been successfully activated.\n\n")
},
}
website.WebsiteCommand.AddCommand(activateUserCommand)
sendTestMailCommand := &cobra.Command{
Use: "sendtestmail [type] [toAddress] [toName]",
Short: "Sends a test mail",
Run: func(cmd *cobra.Command, args []string) {
templates.Init()
if len(args) < 3 {
fmt.Printf("You must provide the email type and recipient details.\n\n")
cmd.Usage()
os.Exit(1)
}
emailType := args[0]
toAddress := args[1]
toName := args[2]
p := perf.MakeNewRequestPerf("admintools", "email test", emailType)
var err error
switch emailType {
case "registration":
err = email.SendRegistrationEmail(toAddress, toName, "test_user", "test_token", p)
default:
fmt.Printf("You must provide a valid email type\n\n")
cmd.Usage()
os.Exit(1)
}
p.EndRequest()
perf.LogPerf(p, logging.Info())
if err != nil {
panic(oops.New(err, "Failed to send test email"))
}
},
}
website.WebsiteCommand.AddCommand(sendTestMailCommand)
} }

View File

@ -11,8 +11,12 @@ import (
"io" "io"
"strconv" "strconv"
"strings" "strings"
"time"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4/pgxpool" "github.com/jackc/pgx/v4/pgxpool"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"golang.org/x/crypto/pbkdf2" "golang.org/x/crypto/pbkdf2"
@ -187,3 +191,47 @@ func UpdatePassword(ctx context.Context, conn *pgxpool.Pool, username string, hp
return nil return nil
} }
func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) {
tag, err := conn.Exec(ctx,
`
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;
`,
models.UserStatusInactive,
time.Now(),
)
if err != nil {
return 0, oops.New(err, "failed to delete inactive users")
}
return tag.RowsAffected(), nil
}
func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
t := time.NewTicker(10 * time.Second)
for {
select {
case <-t.C:
n, err := DeleteInactiveUsers(ctx, conn)
if err == nil {
if n > 0 {
logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users")
}
} else {
logging.Error().Err(err).Msg("Failed to delete expired sessions")
}
case <-ctx.Done():
return
}
}
}()
return done
}

View File

@ -94,6 +94,7 @@ func NewSessionCookie(session *models.Session) *http.Cookie {
Value: session.ID, Value: session.ID,
Domain: config.Config.Auth.CookieDomain, Domain: config.Config.Auth.CookieDomain,
Path: "/",
Expires: time.Now().Add(sessionDuration), Expires: time.Now().Add(sessionDuration),
Secure: config.Config.Auth.CookieSecure, Secure: config.Config.Auth.CookieSecure,

View File

@ -25,6 +25,14 @@ var Config = HMNConfig{
CookieDomain: ".handmade.local", CookieDomain: ".handmade.local",
CookieSecure: false, CookieSecure: false,
}, },
Email: EmailConfig{
ServerAddress: "smtp.example.com",
ServerPort: 587,
FromAddress: "noreply@example.com",
FromAddressPassword: "",
FromName: "Handmade Network Team",
OverrideRecipientEmail: "override@handmadedev.org", // NOTE(asaf): If this is not empty, all emails will be redirected to this address.
},
DigitalOcean: DigitalOceanConfig{ DigitalOcean: DigitalOceanConfig{
AssetsSpacesKey: "", AssetsSpacesKey: "",
AssetsSpacesSecret: "", AssetsSpacesSecret: "",

View File

@ -23,6 +23,7 @@ type HMNConfig struct {
LogLevel zerolog.Level LogLevel zerolog.Level
Postgres PostgresConfig Postgres PostgresConfig
Auth AuthConfig Auth AuthConfig
Email EmailConfig
DigitalOcean DigitalOceanConfig DigitalOcean DigitalOceanConfig
} }
@ -52,6 +53,15 @@ type DigitalOceanConfig struct {
AssetsPublicUrlRoot string AssetsPublicUrlRoot string
} }
type EmailConfig struct {
ServerAddress string
ServerPort int
FromAddress string
FromAddressPassword string
FromName string
OverrideRecipientEmail string
}
func (info PostgresConfig) DSN() string { func (info PostgresConfig) DSN() string {
return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName) return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName)
} }

121
src/email/email.go Normal file
View File

@ -0,0 +1,121 @@
package email
import (
"bytes"
"fmt"
"mime"
"mime/quotedprintable"
"net/smtp"
"regexp"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates"
)
type RegistrationEmailData struct {
Name string
HomepageUrl string
CompleteRegistrationUrl string
}
func SendRegistrationEmail(toAddress string, toName string, username string, completionToken string, perf *perf.RequestPerf) error {
perf.StartBlock("EMAIL", "Registration email")
perf.StartBlock("EMAIL", "Rendering template")
contents, err := renderTemplate("email_registration.html", RegistrationEmailData{
Name: toName,
HomepageUrl: hmnurl.BuildHomepage(),
CompleteRegistrationUrl: hmnurl.BuildEmailConfirmation(username, completionToken),
})
if err != nil {
return err
}
perf.EndBlock()
perf.StartBlock("EMAIL", "Sending email")
err = sendMail(toAddress, toName, "[handmade.network] Registration confirmation", 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 {
return EmailRegex.Match([]byte(address))
}
func renderTemplate(name string, data interface{}) (string, error) {
var buffer bytes.Buffer
template, hasTemplate := templates.Templates[name]
if !hasTemplate {
return "", oops.New(nil, "Template not found: %s", name)
}
err := template.Execute(&buffer, data)
if err != nil {
return "", oops.New(err, "Failed to render template for email")
}
contentString := string(buffer.Bytes())
contentString = strings.ReplaceAll(contentString, "\n", "\r\n")
return contentString, nil
}
func sendMail(toAddress, toName, subject, contentHtml string) error {
if config.Config.Email.OverrideRecipientEmail != "" {
toAddress = config.Config.Email.OverrideRecipientEmail
}
contents := prepMailContents(
makeHeaderAddress(toAddress, toName),
makeHeaderAddress(config.Config.Email.FromAddress, config.Config.Email.FromName),
subject,
contentHtml,
)
return smtp.SendMail(
fmt.Sprintf("%s:%d", config.Config.Email.ServerAddress, config.Config.Email.ServerPort),
smtp.PlainAuth("", config.Config.Email.FromAddress, config.Config.Email.FromAddressPassword, config.Config.Email.ServerAddress),
config.Config.Email.FromAddress,
[]string{toAddress},
contents,
)
}
func makeHeaderAddress(email, fullname string) string {
if fullname != "" {
encoded := mime.BEncoding.Encode("utf-8", fullname)
if encoded == fullname {
encoded = strings.ReplaceAll(encoded, `"`, `\"`)
encoded = fmt.Sprintf("\"%s\"", encoded)
}
return fmt.Sprintf("%s <%s>", encoded, email)
} else {
return email
}
}
func prepMailContents(toLine string, fromLine string, subject string, contentHtml string) []byte {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("To: %s\r\n", toLine))
builder.WriteString(fmt.Sprintf("From: %s\r\n", fromLine))
builder.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject)))
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
builder.WriteString("\r\n")
writer := quotedprintable.NewWriter(&builder)
writer.Write([]byte(contentHtml))
writer.Close()
builder.WriteString("\r\n")
return []byte(builder.String())
}

View File

@ -28,6 +28,7 @@ func TestUrl(t *testing.T) {
func TestHomepage(t *testing.T) { func TestHomepage(t *testing.T) {
AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil) AssertRegexMatch(t, BuildHomepage(), RegexHomepage, nil)
AssertRegexMatch(t, BuildHomepageWithRegistrationSuccess(), RegexHomepage, nil)
AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil) AssertRegexMatch(t, BuildProjectHomepage("hero"), RegexHomepage, nil)
AssertSubdomain(t, BuildProjectHomepage("hero"), "hero") AssertSubdomain(t, BuildProjectHomepage("hero"), "hero")
} }
@ -70,6 +71,14 @@ func TestRegister(t *testing.T) {
AssertRegexMatch(t, BuildRegister(), RegexRegister, nil) AssertRegexMatch(t, BuildRegister(), RegexRegister, nil)
} }
func TestRegistrationSuccess(t *testing.T) {
AssertRegexMatch(t, BuildRegistrationSuccess(), RegexRegistrationSuccess, nil)
}
func TestEmailConfirmation(t *testing.T) {
AssertRegexMatch(t, BuildEmailConfirmation("mruser", "test_token"), RegexEmailConfirmation, map[string]string{"username": "mruser", "token": "test_token"})
}
func TestStaticPages(t *testing.T) { func TestStaticPages(t *testing.T) {
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil) AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
AssertRegexMatch(t, BuildAbout(), RegexAbout, nil) AssertRegexMatch(t, BuildAbout(), RegexAbout, nil)
@ -204,12 +213,12 @@ func TestBlog(t *testing.T) {
} }
func TestBlogThread(t *testing.T) { func TestBlogThread(t *testing.T) {
AssertRegexMatch(t, BuildBlogThread("", 1, "", 1), RegexBlogThread, map[string]string{"threadid": "1"}) AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildBlogThread("", 1, "", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"}) AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"}) AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://"), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 2, 123), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"}) AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 123), RegexBlogThread, map[string]string{"threadid": "1"})
AssertRegexNoMatch(t, BuildBlogThread("", 1, "", 2), RegexBlog) AssertRegexNoMatch(t, BuildBlogThread("", 1, ""), RegexBlog)
AssertSubdomain(t, BuildBlogThread("hero", 1, "", 1), "hero") AssertSubdomain(t, BuildBlogThread("hero", 1, ""), "hero")
} }
func TestBlogPost(t *testing.T) { func TestBlogPost(t *testing.T) {
@ -236,12 +245,6 @@ func TestBlogPostReply(t *testing.T) {
AssertSubdomain(t, BuildBlogPostReply("hero", 1, 2), "hero") AssertSubdomain(t, BuildBlogPostReply("hero", 1, 2), "hero")
} }
func TestBlogPostQuote(t *testing.T) {
AssertRegexMatch(t, BuildBlogPostQuote("", 1, 2), RegexBlogPostQuote, map[string]string{"threadid": "1", "postid": "2"})
AssertRegexNoMatch(t, BuildBlogPostQuote("", 1, 2), RegexBlogPost)
AssertSubdomain(t, BuildBlogPostQuote("hero", 1, 2), "hero")
}
func TestLibrary(t *testing.T) { func TestLibrary(t *testing.T) {
AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil) AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil)
AssertSubdomain(t, BuildLibrary("hero"), "hero") AssertSubdomain(t, BuildLibrary("hero"), "hero")

View File

@ -16,12 +16,17 @@ Any function in this package whose name starts with Build is required to be cove
This helps ensure that we don't generate URLs that can't be routed. This helps ensure that we don't generate URLs that can't be routed.
*/ */
var RegexOldHome = regexp.MustCompile("^/home$")
var RegexHomepage = regexp.MustCompile("^/$") var RegexHomepage = regexp.MustCompile("^/$")
func BuildHomepage() string { func BuildHomepage() string {
return Url("/", nil) return Url("/", nil)
} }
func BuildHomepageWithRegistrationSuccess() string {
return Url("/", []Q{{Name: "registered", Value: "true"}})
}
func BuildProjectHomepage(projectSlug string) string { func BuildProjectHomepage(projectSlug string) string {
defer CatchPanic() defer CatchPanic()
return ProjectUrl("/", nil, projectSlug) return ProjectUrl("/", nil, projectSlug)
@ -57,11 +62,11 @@ func BuildLoginAction(redirectTo string) string {
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}}) return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
} }
var RegexLoginPage = regexp.MustCompile("^/_login$") var RegexLoginPage = regexp.MustCompile("^/login$")
func BuildLoginPage(redirectTo string) string { func BuildLoginPage(redirectTo string) string {
defer CatchPanic() defer CatchPanic()
return Url("/_login", []Q{{Name: "redirect", Value: redirectTo}}) return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
} }
var RegexLogoutAction = regexp.MustCompile("^/logout$") var RegexLogoutAction = regexp.MustCompile("^/logout$")
@ -74,11 +79,34 @@ func BuildLogoutAction(redir string) string {
return Url("/logout", []Q{{"redirect", redir}}) return Url("/logout", []Q{{"redirect", redir}})
} }
var RegexRegister = regexp.MustCompile("^/_register$") var RegexRegister = regexp.MustCompile("^/register$")
func BuildRegister() string { func BuildRegister() string {
defer CatchPanic() defer CatchPanic()
return Url("/_register", nil) return Url("/register", nil)
}
var RegexRegistrationSuccess = regexp.MustCompile("^/registered_successfully$")
func BuildRegistrationSuccess() string {
defer CatchPanic()
return Url("/registered_successfully", nil)
}
// TODO(asaf): Delete the old version a bit after launch
var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P<username>[\w\ \.\,\-@\+\_]+)/(?P<hash>[\d\w]+)/(?P<nonce>.+)[\/]?$`)
var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P<username>[^/]+)/(?P<token>[^/]+)$")
func BuildEmailConfirmation(username, token string) string {
defer CatchPanic()
return Url(fmt.Sprintf("/email_confirmation/%s/%s", url.PathEscape(username), token), nil)
}
var RegexPasswordResetRequest = regexp.MustCompile("^/password_reset$")
func BuildPasswordResetRequest() string {
defer CatchPanic()
return Url("/password_reset", nil)
} }
/* /*
@ -145,7 +173,12 @@ func BuildUserProfile(username string) string {
if len(username) == 0 { if len(username) == 0 {
panic(oops.New(nil, "Username must not be blank")) panic(oops.New(nil, "Username must not be blank"))
} }
return Url("/m/"+username, nil) return Url("/m/"+url.PathEscape(username), nil)
}
// TODO
func BuildUserSettings(username string) string {
return ""
} }
/* /*

View File

@ -352,7 +352,7 @@ func SeedFromFile(seedFile string, afterMigration types.MigrationVersion) {
Migrate(afterMigration) Migrate(afterMigration)
fmt.Println("Executing seed...") fmt.Println("Executing seed...")
cmd := exec.Command("psql", cmd := exec.Command("pg_restore",
"--single-transaction", "--single-transaction",
"--dbname", "--dbname",
config.Config.Postgres.DbName, config.Config.Postgres.DbName,
@ -361,7 +361,7 @@ func SeedFromFile(seedFile string, afterMigration types.MigrationVersion) {
"--username", "--username",
config.Config.Postgres.User, config.Config.Postgres.User,
"--password", "--password",
"-f", "--data-only",
seedFile, seedFile,
) )
fmt.Println("Running command:", cmd) fmt.Println("Running command:", cmd)

View File

@ -5,6 +5,7 @@ import (
"time" "time"
"git.handmade.network/hmn/hmn/src/migration/types" "git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
) )

View File

@ -0,0 +1,79 @@
package migrations
import (
"context"
"fmt"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(FixupUsersForRegistration{})
}
type FixupUsersForRegistration struct{}
func (m FixupUsersForRegistration) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 8, 9, 36, 24, 0, time.UTC))
}
func (m FixupUsersForRegistration) Name() string {
return "FixupUsersForRegistration"
}
func (m FixupUsersForRegistration) Description() string {
return "Remove PendingUser and add the necessary fields to users"
}
func (m FixupUsersForRegistration) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
ALTER TABLE auth_user
ADD status INT NOT NULL DEFAULT 1;
ALTER TABLE auth_user
ADD registration_ip INET;
ALTER TABLE auth_user
ALTER COLUMN is_staff SET DEFAULT FALSE;
ALTER TABLE auth_user
ALTER COLUMN timezone SET DEFAULT 'UTC';
ALTER TABLE auth_user
DROP first_name;
ALTER TABLE auth_user
DROP last_name;
ALTER TABLE auth_user
DROP color_1;
ALTER TABLE auth_user
DROP color_2;
`)
if err != nil {
return oops.New(err, "failed to modify auth_user")
}
fmt.Printf("Setting status on users.\n")
// status = INACTIVE(1) when !is_active && last_login is null
// ACTIVE(2) when is_active
// BANNED(3) when !is_active && last_login is not null
_, err = tx.Exec(ctx, `
UPDATE auth_user
SET status = CASE is_active WHEN TRUE THEN 2 ELSE (CASE WHEN last_login IS NULL THEN 1 ELSE 3 END) END;
`)
if err != nil {
return oops.New(err, "failed to set user status")
}
_, err = tx.Exec(ctx, `
ALTER TABLE auth_user
DROP is_active;
`)
if err != nil {
return oops.New(err, "failed to drop is_active")
}
return nil
}
func (m FixupUsersForRegistration) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,61 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(AddDeletionIndices{})
}
type AddDeletionIndices struct{}
func (m AddDeletionIndices) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 8, 11, 9, 26, 0, time.UTC))
}
func (m AddDeletionIndices) Name() string {
return "AddDeletionIndices"
}
func (m AddDeletionIndices) Description() string {
return "Add indices to tables that depend on auth_user to allow for faster deletion of users"
}
func (m AddDeletionIndices) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `DROP TABLE auth_user_user_permissions;`)
if err != nil {
return oops.New(err, "failed to drop auth_user_user_permissions")
}
_, err = tx.Exec(ctx, `DROP TABLE django_admin_log;`)
if err != nil {
return oops.New(err, "failed to drop django_admin_log")
}
_, err = tx.Exec(ctx, `
CREATE INDEX handmade_communicationchoice_userid ON handmade_communicationchoice (user_id);
CREATE INDEX handmade_communicationsubcategory_userid ON handmade_communicationsubcategory (user_id);
CREATE INDEX handmade_communicationsubthread_userid ON handmade_communicationsubthread (user_id);
CREATE INDEX handmade_discord_hmnuserid ON handmade_discord (hmn_user_id);
CREATE INDEX handmade_links_userid ON handmade_links (user_id);
CREATE INDEX handmade_passwordresetrequest_userid ON handmade_passwordresetrequest (user_id);
CREATE INDEX handmade_post_authorid ON handmade_post (author_id);
CREATE INDEX handmade_postversion_editorid ON handmade_postversion (editor_id);
CREATE INDEX handmade_thread_personalarticleuserid ON handmade_thread (personal_article_user_id);
CREATE INDEX handmade_user_projects_userid ON handmade_user_projects (user_id);
`)
if err != nil {
return oops.New(err, "failed to create user_id indices")
}
return nil
}
func (m AddDeletionIndices) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,119 @@
package migrations
import (
"context"
"fmt"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(DeleteInactiveUsers{})
}
type DeleteInactiveUsers struct{}
func (m DeleteInactiveUsers) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 8, 13, 46, 55, 0, time.UTC))
}
func (m DeleteInactiveUsers) Name() string {
return "DeleteInactiveUsers"
}
func (m DeleteInactiveUsers) Description() string {
return "Delete inactive users and expired onetimetokens"
}
func (m DeleteInactiveUsers) Up(ctx context.Context, tx pgx.Tx) error {
var err error
res, err := tx.Exec(ctx,
`
DELETE FROM handmade_passwordresetrequest
USING handmade_onetimetoken
WHERE handmade_onetimetoken.expires < $1 AND handmade_passwordresetrequest.confirmation_token_id = handmade_onetimetoken.id;
`,
time.Now(),
)
if err != nil {
return oops.New(err, "failed to delete password reset requests")
}
fmt.Printf("Deleted %v expired password reset requests.\n", res.RowsAffected())
fmt.Printf("Deleting inactive users. This might take a minute.\n")
res, err = tx.Exec(ctx,
`
DELETE FROM auth_user
WHERE status = 1 AND date_joined < $1;
`,
time.Now().Add(-(time.Hour * 24 * 7)),
)
if err != nil {
return oops.New(err, "failed to delete inactive users")
}
fmt.Printf("Deleted %v inactive users.\n", res.RowsAffected())
_, err = tx.Exec(ctx, `
ALTER TABLE handmade_onetimetoken
DROP used;
ALTER TABLE handmade_onetimetoken
ADD owner_id INT REFERENCES auth_user(id) ON DELETE CASCADE;
ALTER TABLE handmade_userpending
DROP CONSTRAINT handma_activation_token_id_0b4a4b06_fk_handmade_onetimetoken_id;
`)
_, err = tx.Exec(ctx, `
UPDATE handmade_userpending
SET activation_token_id = NULL
WHERE (SELECT count(*) AS ct FROM handmade_onetimetoken WHERE id = activation_token_id) = 0;
`)
res, err = tx.Exec(ctx,
`
DELETE FROM handmade_onetimetoken
WHERE expires < $1
`,
time.Now(),
)
if err != nil {
return oops.New(err, "failed to delete expired tokens")
}
fmt.Printf("Deleted %v expired tokens.\n", res.RowsAffected())
fmt.Printf("Setting owner_id on onetimetoken\n")
_, err = tx.Exec(ctx, `
UPDATE handmade_onetimetoken
SET owner_id = (SELECT id FROM auth_user WHERE username = (SELECT username FROM handmade_userpending WHERE activation_token_id = handmade_onetimetoken.id LIMIT 1))
WHERE token_type = 1;
`)
if err != nil {
return oops.New(err, "failed to set owner_id on onetimetoken")
}
_, err = tx.Exec(ctx, `
UPDATE handmade_onetimetoken
SET owner_id = (SELECT user_id FROM handmade_passwordresetrequest WHERE confirmation_token_id = handmade_onetimetoken.id)
WHERE token_type = 2;
`)
if err != nil {
return oops.New(err, "failed to set owner_id on onetimetoken")
}
fmt.Printf("Setting registration_ip on auth_user\n")
_, err = tx.Exec(ctx, `
UPDATE auth_user
SET registration_ip = (SELECT ip FROM handmade_userpending WHERE handmade_userpending.username = auth_user.username LIMIT 1);
`)
if err != nil {
return oops.New(err, "failed to set owner_id on onetimetoken")
}
return nil
}
func (m DeleteInactiveUsers) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,60 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(FinalizeOneTimeTokenChanges{})
}
type FinalizeOneTimeTokenChanges struct{}
func (m FinalizeOneTimeTokenChanges) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 8, 8, 14, 18, 19, 0, time.UTC))
}
func (m FinalizeOneTimeTokenChanges) Name() string {
return "FinalizeOneTimeTokenChanges"
}
func (m FinalizeOneTimeTokenChanges) Description() string {
return "Create an index and set not-null"
}
func (m FinalizeOneTimeTokenChanges) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
ALTER TABLE handmade_onetimetoken
ALTER COLUMN owner_id SET NOT NULL;
CREATE INDEX handmade_onetimetoken_ownerid_type ON handmade_onetimetoken(owner_id, token_type);
`)
if err != nil {
return oops.New(err, "Create index on onetimetoken")
}
_, err = tx.Exec(ctx, `
DROP TABLE handmade_userpending;
`)
if err != nil {
return oops.New(err, "failed to drop handmade_userpending")
}
_, err = tx.Exec(ctx, `
DROP TABLE handmade_passwordresetrequest;
`)
if err != nil {
return oops.New(err, "failed to drop handmade_passwordresetrequest")
}
return nil
}
func (m FinalizeOneTimeTokenChanges) Down(ctx context.Context, tx pgx.Tx) error {
panic("Implement me")
}

View File

@ -0,0 +1,27 @@
package models
import (
"time"
"github.com/google/uuid"
)
type OneTimeTokenType int
const (
TokenTypeRegistration OneTimeTokenType = iota + 1
TokenTypePasswordReset
)
type OneTimeToken struct {
ID int `db:"id"`
OwnerID int `db:"owner_id"`
Type OneTimeTokenType `db:"token_type`
Created time.Time `db:"created"`
Expires time.Time `db:"expires"`
Content string `db:"token_content"`
}
func GenerateToken() string {
return uuid.New().String()
}

View File

@ -7,6 +7,14 @@ import (
var UserType = reflect.TypeOf(User{}) var UserType = reflect.TypeOf(User{})
type UserStatus int
const (
UserStatusInactive UserStatus = iota + 1
UserStatusActive
UserStatusBanned
)
type User struct { type User struct {
ID int `db:"id"` ID int `db:"id"`
@ -17,8 +25,8 @@ type User struct {
DateJoined time.Time `db:"date_joined"` DateJoined time.Time `db:"date_joined"`
LastLogin *time.Time `db:"last_login"` LastLogin *time.Time `db:"last_login"`
IsStaff bool `db:"is_staff"` IsStaff bool `db:"is_staff"`
IsActive bool `db:"is_active"` Status UserStatus `db:"status"`
Name string `db:"name"` Name string `db:"name"`
Bio string `db:"bio"` Bio string `db:"bio"`
@ -26,10 +34,8 @@ type User struct {
Signature string `db:"signature"` Signature string `db:"signature"`
Avatar *string `db:"avatar"` // TODO: Image field stuff? Avatar *string `db:"avatar"` // TODO: Image field stuff?
DarkTheme bool `db:"darktheme"` DarkTheme bool `db:"darktheme"`
Timezone string `db:"timezone"` Timezone string `db:"timezone"`
ProfileColor1 string `db:"color_1"`
ProfileColor2 string `db:"color_2"`
ShowEmail bool `db:"showemail"` ShowEmail bool `db:"showemail"`
CanEditLibrary bool `db:"edit_library"` CanEditLibrary bool `db:"edit_library"`
@ -39,3 +45,7 @@ type User struct {
MarkedAllReadAt time.Time `db:"marked_all_read_at"` MarkedAllReadAt time.Time `db:"marked_all_read_at"`
} }
func (u *User) IsActive() bool {
return u.Status == UserStatusActive
}

View File

@ -2,7 +2,10 @@ package perf
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/rs/zerolog"
) )
type RequestPerf struct { type RequestPerf struct {
@ -132,3 +135,15 @@ func (perfCollector *PerfCollector) GetPerfCopy() *PerfStorage {
perfStorageCopy := <-resultChan perfStorageCopy := <-resultChan
return &perfStorageCopy return &perfStorageCopy
} }
func LogPerf(perf *RequestPerf, log *zerolog.Event) {
blockStack := make([]time.Time, 0)
for i, block := range perf.Blocks {
for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) {
blockStack = blockStack[:len(blockStack)-1]
}
log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
blockStack = append(blockStack, block.End)
}
log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", perf.Method, perf.Path, float64(perf.End.Sub(perf.Start).Nanoseconds())/1000/1000))
}

View File

@ -33,3 +33,7 @@
.notice-success { .notice-success {
@include usevar(background-color, notice-success-color); @include usevar(background-color, notice-success-color);
} }
.notice-failure {
@include usevar(background-color, notice-failure-color);
}

View File

@ -50,6 +50,7 @@ $vars: (
notice-lts-color: #2a681d, notice-lts-color: #2a681d,
notice-lts-reqd-color: #876327, notice-lts-reqd-color: #876327,
notice-success-color: #2a681d, notice-success-color: #2a681d,
notice-failure-color: #7a2020,
optionbar-border-color: #333, optionbar-border-color: #333,

View File

@ -50,6 +50,7 @@ $vars: (
notice-lts-color: #43a52f, notice-lts-color: #43a52f,
notice-lts-reqd-color: #aa7d30, notice-lts-reqd-color: #aa7d30,
notice-success-color: #43a52f, notice-success-color: #43a52f,
notice-failure-color: #b42222,
optionbar-border-color: #ccc, optionbar-border-color: #ccc,

View File

@ -160,10 +160,8 @@ func UserToTemplate(u *models.User, currentTheme string) User {
AvatarUrl: UserAvatarUrl(u, currentTheme), AvatarUrl: UserAvatarUrl(u, currentTheme),
ProfileUrl: hmnurl.BuildUserProfile(u.Username), ProfileUrl: hmnurl.BuildUserProfile(u.Username),
DarkTheme: u.DarkTheme, DarkTheme: u.DarkTheme,
Timezone: u.Timezone, Timezone: u.Timezone,
ProfileColor1: u.ProfileColor1,
ProfileColor2: u.ProfileColor2,
CanEditLibrary: u.CanEditLibrary, CanEditLibrary: u.CanEditLibrary,
DiscordSaveShowcase: u.DiscordSaveShowcase, DiscordSaveShowcase: u.DiscordSaveShowcase,

View File

@ -0,0 +1,19 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<form method="POST">
<input type="hidden" name="token" value="{{ .Token }}" />
<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 />
<input class="db mt3" type="submit" value="Complete registration" />
</form>
</div>
{{ end }}

View File

@ -0,0 +1,13 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<h1>Welcome to Handmade Network, {{ .User.Name }}</h1>
<p class="b">Here's some interesting stuff you can find here:</p>
<ul>
<li>We have an active <a href="{{ .DiscordUrl }}">Discord</a> community</li>
<li>We have a <a href="{{ .PodcastUrl }}">Podcast</a></li>
</ul>
{{/* TODO(asaf): Describe what users can do on the network? */}}
</div>
{{ end }}

View File

@ -0,0 +1,25 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<h1>Please enter your username and password</h1>
<form method="POST">
<input type="hidden" name="redirect" value="{{ .RedirectUrl }}" />
<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="password">Password</label>
<input class="db w5" name="password" minlength="8" type="password" required />
</p>
<a href="{{ .ForgotPasswordUrl }}">Forgot your password?</a>
<p class="mt3">
<input type="submit" value="Log in" />
</p>
</form>
</div>
{{ end }}

View File

@ -0,0 +1,55 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<form id="register_form" method="post">
{{/* NOTE(asaf): No CSRF on register. We don't have a user session yet and I don't think we would gain anything from a pre-login session here */}}
<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}$" />
<span class="note db">Required. You may use up to 30 characters. Must start with a letter or number. Dashes and underscores are allowed.</span>
</p>
<p class="mb2">
<label class="db b" for="displayname">Display name</label>
<input class="db w5" name="displayname" type="text" />
<span class="note db">Optional.</span>
</p>
<p class="mb2">
<label class="db b" for="email">Email</label>
<input class="db w5" name="email" type="email" required />
</p>
<p class="mb2">
<label class="db b" for="password">Password</label>
<input class="db w5" name="password" minlength="8" type="password" required />
</p>
<p class="mb2">
<label class="db b" for="password2">Password confirmation</label>
<input class="db w5" name="password2" minlength="8" type="password" required />
<span class="note db">Enter the same password as before, for verification.</span>
</p>
{{/* TODO(asaf): Consider adding some bot-mitigation thing here */}}
<p>
<input type="submit" value="Register" />
</p>
</form>
<script>
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) {
if (password1.value != password2.value) {
password2.setCustomValidity("Password doesn't match");
} else {
password2.setCustomValidity("");
}
});
</script>
</div>
{{ end }}

View File

@ -0,0 +1,11 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
<h1>Hi! You're almost done signing up.</h1>
<p class="mb3 b">We've sent you an email with a confirmation link. Please follow it to complete the registration process.</p>
<p>If for some reason the email doesn't arrive in a timely fashion and you also can't find it in your spam trap,<br />
you should feel free to <a href="{{ .ContactUsUrl }}">contact the staff</a> and ask us to activate you manually.<br />
You'll want to tell us the username you chose and preferably email us from the same address you used to sign up.</p>
</div>
{{ end }}

View File

@ -0,0 +1,13 @@
<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>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 registered with your email address at <a href="{{ .HomepageUrl }}">Handmade.Network</a>.<br />
If that wasn't you, kindly ignore this email. If you do not complete the registration, your information will be deleted from our servers after 7 days.
</p>

View File

@ -80,6 +80,7 @@
<body class="{{ join " " .BodyClasses }}"> <body class="{{ join " " .BodyClasses }}">
<div class="content mw-site ph3-m ph4-l"> <div class="content mw-site ph3-m ph4-l">
{{ template "header.html" . }} {{ template "header.html" . }}
{{ template "notices.html" .Notices }}
{{ with .Breadcrumbs }} {{ with .Breadcrumbs }}
<div class="tc tl-ns ph2 ph0-ns pb2 pb0-ns"> <div class="tc tl-ns ph2 ph0-ns pb2 pb0-ns">
{{ range $i, $e := . -}} {{ range $i, $e := . -}}

View File

@ -2,7 +2,6 @@
{{ define "content" }} {{ define "content" }}
<div class="content-block"> <div class="content-block">
{{ template "notices.html" .Notices }}
<form id="podcast_form" method="POST" enctype="multipart/form-data"> <form id="podcast_form" method="POST" enctype="multipart/form-data">
{{ csrftoken .Session }} {{ csrftoken .Session }}
<input class="b w-100 mb1" type="text" name="title" required placeholder="Podcast title..." value="{{ .Podcast.Title }}" /> <input class="b w-100 mb1" type="text" name="title" required placeholder="Podcast title..." value="{{ .Podcast.Title }}" />

View File

@ -2,7 +2,6 @@
{{ define "content" }} {{ define "content" }}
<div class="content-block"> <div class="content-block">
{{ template "notices.html" .Notices }}
<h1>{{ if .IsEdit }}Edit{{ else }}New{{ end }} Episode</h1> <h1>{{ if .IsEdit }}Edit{{ else }}New{{ end }} Episode</h1>
<form method="POST"> <form method="POST">
{{ csrftoken .Session }} {{ csrftoken .Session }}

View File

@ -9,7 +9,6 @@
{{ define "content" }} {{ define "content" }}
<div class="flex flex-column flex-row-l"> <div class="flex flex-column flex-row-l">
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
{{ template "notices.html" .Notices }}
{{ with .Screenshots }} {{ with .Screenshots }}
<div class="carousel-container mw-100 mv2 mv3-ns margin-center"> <div class="carousel-container mw-100 mv2 mv3-ns margin-center">
<div class="carousel aspect-ratio aspect-ratio--16x9 overflow-hidden bg--dim br2-ns"> <div class="carousel aspect-ratio aspect-ratio--16x9 overflow-hidden bg--dim br2-ns">

View File

@ -13,6 +13,7 @@ type BaseData struct {
Theme string Theme string
BodyClasses []string BodyClasses []string
Breadcrumbs []Breadcrumb Breadcrumbs []Breadcrumb
Notices []Notice
CurrentUrl string CurrentUrl string
LoginPageUrl string LoginPageUrl string
@ -128,10 +129,8 @@ type User struct {
AvatarUrl string AvatarUrl string
ProfileUrl string ProfileUrl string
DarkTheme bool DarkTheme bool
Timezone string Timezone string
ProfileColor1 string
ProfileColor2 string
CanEditLibrary bool CanEditLibrary bool
DiscordSaveShowcase bool DiscordSaveShowcase bool
@ -172,6 +171,7 @@ type PodcastEpisode struct {
Duration int Duration int
} }
// NOTE(asaf): See /src/rawdata/scss/_notices.scss for a list of classes.
type Notice struct { type Notice struct {
Content template.HTML Content template.HTML
Class string Class string
@ -296,3 +296,10 @@ type Pagination struct {
PreviousUrl string PreviousUrl string
NextUrl string NextUrl string
} }
type EmailBaseData struct {
To template.HTML
From template.HTML
Subject template.HTML
Separator template.HTML
}

View File

@ -317,6 +317,10 @@ func Index(c *RequestContext) ResponseData {
baseData := getBaseData(c) baseData := getBaseData(c)
baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more? baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more?
if c.Req.URL.Query().Get("registered") != "" {
baseData.Notices = []templates.Notice{{Content: "You've completed your registration successfully!", Class: "success"}}
}
var res ResponseData var res ResponseData
err = res.WriteTemplate("landing.html", LandingTemplateData{ err = res.WriteTemplate("landing.html", LandingTemplateData{
BaseData: baseData, BaseData: baseData,

View File

@ -2,17 +2,55 @@ package website
import ( import (
"errors" "errors"
"fmt"
"html/template"
"net/http" "net/http"
"regexp"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
) )
// TODO(asaf): Add a middleware that guarantees the certain handlers will take at least X amount of time.
// Will be relevant for:
// * Login POST
// * Register POST
var UsernameRegex = regexp.MustCompile(`^[0-9a-zA-Z][\w-]{2,29}$`)
type LoginPageData struct {
templates.BaseData
RedirectUrl string
ForgotPasswordUrl string
}
func LoginPage(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return RejectRequest(c, "You are already logged in.")
}
var res ResponseData
res.MustWriteTemplate("auth_login.html", LoginPageData{
BaseData: getBaseData(c),
RedirectUrl: c.Req.URL.Query().Get("redirect"),
ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
}, c.Perf)
return res
}
func Login(c *RequestContext) ResponseData { func Login(c *RequestContext) ResponseData {
// TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks. // TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks.
if c.CurrentUser != nil {
return RejectRequest(c, "You are already logged in.")
}
form, err := c.GetFormValues() form, err := c.GetFormValues()
if err != nil { if err != nil {
@ -22,7 +60,7 @@ func Login(c *RequestContext) ResponseData {
username := form.Get("username") username := form.Get("username")
password := form.Get("password") password := form.Get("password")
if username == "" || password == "" { if username == "" || password == "" {
return ErrorResponse(http.StatusBadRequest, NewSafeError(err, "you must provide both a username and password")) return RejectRequest(c, "You must provide both a username and password")
} }
redirect := form.Get("redirect") redirect := form.Get("redirect")
@ -33,52 +71,49 @@ func Login(c *RequestContext) ResponseData {
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE LOWER(username) = LOWER($1)", username) 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 err != nil {
if errors.Is(err, db.ErrNoMatchingRows) { if errors.Is(err, db.ErrNoMatchingRows) {
return ResponseData{ var res ResponseData
StatusCode: http.StatusUnauthorized, 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
} else { } else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username")) return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
} }
} }
user := userRow.(*models.User) user := userRow.(*models.User)
hashed, err := auth.ParsePasswordString(user.Password) success, err := tryLogin(c, user, password)
if err != nil { if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse password string")) return ErrorResponse(http.StatusInternalServerError, err)
} }
passwordsMatch, err := auth.CheckPassword(password, hashed) if !success {
if err != nil { var res ResponseData
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check password against hash")) baseData := getBaseData(c)
} baseData.Notices = []templates.Notice{{Content: "Incorrect username or password", Class: "failure"}}
res.MustWriteTemplate("auth_login.html", LoginPageData{
if passwordsMatch { BaseData: baseData,
// re-hash and save the user's password if necessary RedirectUrl: redirect,
if hashed.IsOutdated() { ForgotPasswordUrl: hmnurl.BuildPasswordResetRequest(),
newHashed, err := auth.HashPassword(password) }, c.Perf)
if err == nil {
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to update user's password")
}
} else {
c.Logger.Error().Err(err).Msg("failed to re-hash password")
}
// If errors happen here, we can still continue with logging them in
}
session, err := auth.CreateSession(c.Context(), c.Conn, user.Username)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create session"))
}
res := c.Redirect(redirect, http.StatusSeeOther)
res.SetCookie(auth.NewSessionCookie(session))
return res return res
} else {
return c.Redirect("/", http.StatusSeeOther) // TODO: Redirect to standalone login page with error
} }
if user.Status == models.UserStatusInactive {
return RejectRequest(c, "You must validate your email address before logging in. You should've received an email shortly after registration. If you did not receive the email, please contact the staff.")
}
res := c.Redirect(redirect, http.StatusSeeOther)
err = loginUser(c, user, &res)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
}
return res
} }
func Logout(c *RequestContext) ResponseData { func Logout(c *RequestContext) ResponseData {
@ -101,3 +136,452 @@ func Logout(c *RequestContext) ResponseData {
return res return res
} }
func RegisterNewUser(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
c.Redirect(hmnurl.BuildUserSettings(c.CurrentUser.Username), http.StatusSeeOther)
}
// TODO(asaf): Do something to prevent bot registration
var res ResponseData
res.MustWriteTemplate("auth_register.html", getBaseData(c), c.Perf)
return res
}
func RegisterNewUserSubmit(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return RejectRequest(c, "Can't register new user. You are already logged in")
}
c.Req.ParseForm()
username := strings.TrimSpace(c.Req.Form.Get("username"))
displayName := strings.TrimSpace(c.Req.Form.Get("displayname"))
emailAddress := strings.TrimSpace(c.Req.Form.Get("email"))
password := c.Req.Form.Get("password")
password2 := c.Req.Form.Get("password2")
if !UsernameRegex.Match([]byte(username)) {
return RejectRequest(c, "Invalid username")
}
if !email.IsEmail(emailAddress) {
return RejectRequest(c, "Invalid email address")
}
if len(password) < 8 {
return RejectRequest(c, "Password too short")
}
if password != password2 {
return RejectRequest(c, "Password confirmation doesn't match password")
}
c.Perf.StartBlock("SQL", "Check for existing usernames and emails")
userAlreadyExists := true
_, err := db.QueryInt(c.Context(), c.Conn,
`
SELECT id
FROM auth_user
WHERE LOWER(username) = LOWER($1)
`,
username,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
userAlreadyExists = false
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
}
}
if userAlreadyExists {
return RejectRequest(c, fmt.Sprintf("Username (%s) already exists.", username))
}
emailAlreadyExists := true
_, err = db.QueryInt(c.Context(), c.Conn,
`
SELECT id
FROM auth_user
WHERE LOWER(email) = LOWER($1)
`,
emailAddress,
)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
emailAlreadyExists = false
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
}
}
c.Perf.EndBlock()
if emailAlreadyExists {
// 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)
}
hashed, err := auth.HashPassword(password)
if err != nil {
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 {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
}
defer tx.Rollback(c.Context())
now := time.Now()
var newUserId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO auth_user (username, email, password, date_joined, name, registration_ip)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`,
username, emailAddress, hashed.String(), now, displayName, c.GetIP(),
).Scan(&newUserId)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to store user"))
}
ott := models.GenerateToken()
_, err = tx.Exec(c.Context(),
`
INSERT INTO handmade_onetimetoken (token_type, created, expires, token_content, owner_id)
VALUES($1, $2, $3, $4, $5)
`,
models.TokenTypeRegistration,
now,
now.Add(time.Hour*24*7),
ott,
newUserId,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to store one-time token"))
}
c.Perf.EndBlock()
mailName := displayName
if mailName == "" {
mailName = username
}
err = email.SendRegistrationEmail(emailAddress, mailName, username, ott, c.Perf)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to send registration email"))
}
c.Perf.StartBlock("SQL", "Commit user")
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit user to the db"))
}
c.Perf.EndBlock()
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
type RegisterNewUserSuccessData struct {
templates.BaseData
ContactUsUrl string
}
func RegisterNewUserSuccess(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
var res ResponseData
res.MustWriteTemplate("auth_register_success.html", RegisterNewUserSuccessData{
BaseData: getBaseData(c),
ContactUsUrl: hmnurl.BuildContactPage(),
}, c.Perf)
return res
}
type EmailValidationData struct {
templates.BaseData
Token string
Username string
}
func EmailConfirmation(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
username, hasUsername := c.PathParams["username"]
if !hasUsername {
return RejectRequest(c, "Bad validation url")
}
token := ""
hasToken := false
// TODO(asaf): Delete old hash/nonce about a week after launch
hash, hasHash := c.PathParams["hash"]
nonce, hasNonce := c.PathParams["nonce"]
if hasHash && hasNonce {
token = fmt.Sprintf("%s/%s", hash, nonce)
hasToken = true
} else {
token, hasToken = c.PathParams["token"]
}
if !hasToken {
return RejectRequest(c, "Bad validation url")
}
validationResult := validateUsernameAndToken(c, username, token)
if !validationResult.IsValid {
return validationResult.InvalidResponse
}
var res ResponseData
res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{
BaseData: getBaseData(c),
Token: token,
Username: username,
}, c.Perf)
return res
}
func EmailConfirmationSubmit(c *RequestContext) ResponseData {
c.Req.ParseForm()
token := c.Req.Form.Get("token")
username := c.Req.Form.Get("username")
password := c.Req.Form.Get("password")
validationResult := validateUsernameAndToken(c, username, token)
if !validationResult.IsValid {
return validationResult.InvalidResponse
}
success, err := tryLogin(c, validationResult.User, password)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
} else if !success {
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"}}
res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{
BaseData: getBaseData(c),
Token: token,
Username: username,
}, c.Perf)
return res
}
c.Perf.StartBlock("SQL", "Updating user status and deleting 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())
_, 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 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 one time token"))
}
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit transaction"))
}
c.Perf.EndBlock()
res := c.Redirect(hmnurl.BuildHomepageWithRegistrationSuccess(), http.StatusSeeOther)
err = loginUser(c, validationResult.User, &res)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, err)
}
return res
}
// 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)
}
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)
}
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)
}
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 tryLogin(c *RequestContext, user *models.User, password string) (bool, error) {
c.Perf.StartBlock("AUTH", "Checking password")
defer c.Perf.EndBlock()
hashed, err := auth.ParsePasswordString(user.Password)
if err != nil {
return false, oops.New(err, "failed to parse password string")
}
passwordsMatch, err := auth.CheckPassword(password, hashed)
if err != nil {
return false, oops.New(err, "failed to check password against hash")
}
if !passwordsMatch {
return false, nil
}
// re-hash and save the user's password if necessary
if hashed.IsOutdated() {
newHashed, err := auth.HashPassword(password)
if err == nil {
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to update user's password")
}
} else {
c.Logger.Error().Err(err).Msg("failed to re-hash password")
}
// If errors happen here, we can still continue with logging them in
}
return true, nil
}
func loginUser(c *RequestContext, user *models.User, responseData *ResponseData) error {
c.Perf.StartBlock("SQL", "Setting last login and creating session")
defer c.Perf.EndBlock()
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return oops.New(err, "failed to start db transaction")
}
defer tx.Rollback(c.Context())
now := time.Now()
_, err = tx.Exec(c.Context(),
`
UPDATE auth_user
SET last_login = $1
WHERE id = $2
`,
now,
user.ID,
)
if err != nil {
return oops.New(err, "failed to update last_login for user")
}
session, err := auth.CreateSession(c.Context(), c.Conn, user.Username)
if err != nil {
return oops.New(err, "failed to create session")
}
err = tx.Commit(c.Context())
if err != nil {
return oops.New(err, "failed to commit transaction")
}
responseData.SetCookie(auth.NewSessionCookie(session))
return nil
}
type validateUserAndTokenResult struct {
User *models.User
OneTimeToken *models.OneTimeToken
IsValid bool
InvalidResponse ResponseData
}
func validateUsernameAndToken(c *RequestContext, username string, token string) validateUserAndTokenResult {
c.Perf.StartBlock("SQL", "Check username and token")
defer c.Perf.EndBlock()
type userAndTokenQuery struct {
User models.User `db:"auth_user"`
OneTimeToken *models.OneTimeToken `db:"onetimetoken"`
}
row, err := db.QueryOne(c.Context(), c.Conn, userAndTokenQuery{},
`
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)
`,
username,
)
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"))
}
} else {
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
}
}
return result
}

View File

@ -74,7 +74,6 @@ func PodcastIndex(c *RequestContext) ResponseData {
type PodcastEditData struct { type PodcastEditData struct {
templates.BaseData templates.BaseData
Podcast templates.Podcast Podcast templates.Podcast
Notices []templates.Notice
} }
func PodcastEdit(c *RequestContext) ResponseData { func PodcastEdit(c *RequestContext) ResponseData {
@ -102,7 +101,7 @@ func PodcastEdit(c *RequestContext) ResponseData {
success := c.URL().Query().Get("success") success := c.URL().Query().Get("success")
if success != "" { if success != "" {
podcastEditData.Notices = append(podcastEditData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."}) podcastEditData.BaseData.Notices = append(podcastEditData.BaseData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."})
} }
var res ResponseData var res ResponseData
@ -312,7 +311,6 @@ type PodcastEpisodeEditData struct {
EpisodeNumber int EpisodeNumber int
CurrentFile string CurrentFile string
EpisodeFiles []string EpisodeFiles []string
Notices []templates.Notice
} }
func PodcastEpisodeNew(c *RequestContext) ResponseData { func PodcastEpisodeNew(c *RequestContext) ResponseData {
@ -392,7 +390,7 @@ func PodcastEpisodeEdit(c *RequestContext) ResponseData {
success := c.URL().Query().Get("success") success := c.URL().Query().Get("success")
if success != "" { if success != "" {
podcastEpisodeEditData.Notices = append(podcastEpisodeEditData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."}) podcastEpisodeEditData.BaseData.Notices = append(podcastEpisodeEditData.BaseData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."})
} }
var res ResponseData var res ResponseData

View File

@ -209,7 +209,6 @@ type ProjectHomepageData struct {
templates.BaseData templates.BaseData
Project templates.Project Project templates.Project
Owners []templates.User Owners []templates.User
Notices []templates.Notice
Screenshots []string Screenshots []string
ProjectLinks []templates.Link ProjectLinks []templates.Link
Licenses []templates.Link Licenses []templates.Link
@ -385,7 +384,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
Class: "hidden", Class: "hidden",
Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.", Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
} }
projectHomepageData.Notices = append(projectHomepageData.Notices, hiddenNotice) projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, hiddenNotice)
} }
if project.Lifecycle != models.ProjectLifecycleActive { if project.Lifecycle != models.ProjectLifecycleActive {
@ -413,7 +412,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
lifecycleNotice.Class = "lts" lifecycleNotice.Class = "lts"
lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.") lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.")
} }
projectHomepageData.Notices = append(projectHomepageData.Notices, lifecycleNotice) projectHomepageData.BaseData.Notices = append(projectHomepageData.BaseData.Notices, lifecycleNotice)
} }
for _, screenshot := range screenshotQueryResult.ToSlice() { for _, screenshot := range screenshotQueryResult.ToSlice() {

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"html" "html"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
@ -142,6 +143,37 @@ func (c *RequestContext) FullUrl() string {
return scheme + c.Req.Host + c.Req.URL.String() return scheme + c.Req.Host + c.Req.URL.String()
} }
func (c *RequestContext) GetIP() *net.IPNet {
ipString := ""
if ipString == "" {
cf, hasCf := c.Req.Header["CF-Connecting-IP"]
if hasCf {
ipString = cf[0]
}
}
if ipString == "" {
forwarded, hasForwarded := c.Req.Header["X-Forwarded-For"]
if hasForwarded {
ipString = forwarded[0]
}
}
if ipString == "" {
ipString = c.Req.RemoteAddr
}
if ipString != "" {
_, res, err := net.ParseCIDR(fmt.Sprintf("%s/32", ipString))
if err == nil {
return res
}
}
return nil
}
func (c *RequestContext) GetFormValues() (url.Values, error) { func (c *RequestContext) GetFormValues() (url.Values, error) {
err := c.Req.ParseForm() err := c.Req.ParseForm()
if err != nil { if err != nil {

View File

@ -116,10 +116,6 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
} }
} }
// TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware.
routes.POST(hmnurl.RegexLoginAction, Login)
routes.GET(hmnurl.RegexLogoutAction, Logout)
routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData { routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req) http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
@ -144,6 +140,19 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
// TODO(asaf): Have separate middleware for HMN-only routes and any-project routes // TODO(asaf): Have separate middleware for HMN-only routes and any-project routes
// NOTE(asaf): HMN-only routes: // NOTE(asaf): HMN-only routes:
mainRoutes.GET(hmnurl.RegexOldHome, Index)
mainRoutes.POST(hmnurl.RegexLoginAction, Login)
mainRoutes.GET(hmnurl.RegexLogoutAction, Logout)
mainRoutes.GET(hmnurl.RegexLoginPage, LoginPage)
mainRoutes.GET(hmnurl.RegexRegister, RegisterNewUser)
mainRoutes.POST(hmnurl.RegexRegister, RegisterNewUserSubmit)
mainRoutes.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
mainRoutes.GET(hmnurl.RegexOldEmailConfirmation, EmailConfirmation) // TODO(asaf): Delete this a bit after launch
mainRoutes.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
mainRoutes.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
mainRoutes.GET(hmnurl.RegexFeed, Feed) mainRoutes.GET(hmnurl.RegexFeed, Feed)
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
mainRoutes.GET(hmnurl.RegexShowcase, Showcase) mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
@ -229,7 +238,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf) UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()), LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()), LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf) RegisterUrl: hmnurl.BuildRegister(),
HMNHomepageUrl: hmnurl.BuildHomepage(), HMNHomepageUrl: hmnurl.BuildHomepage(),
ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug), ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1), ProjectIndexUrl: hmnurl.BuildProjectIndex(1),

View File

@ -40,6 +40,7 @@ var WebsiteCommand = &cobra.Command{
backgroundJobsDone := zipJobs( backgroundJobsDone := zipJobs(
auth.PeriodicallyDeleteExpiredSessions(backgroundJobContext, conn), auth.PeriodicallyDeleteExpiredSessions(backgroundJobContext, conn),
auth.PeriodicallyDeleteInactiveUsers(backgroundJobContext, conn),
perfCollector.Done, perfCollector.Done,
) )