Registration flow and email
This commit is contained in:
parent
038ee7e90e
commit
660f65ba95
|
@ -9520,3 +9520,7 @@ span.icon-rss::before {
|
|||
.notice-success {
|
||||
background-color: #43a52f;
|
||||
background-color: var(--notice-success-color); }
|
||||
|
||||
.notice-failure {
|
||||
background-color: #b42222;
|
||||
background-color: var(--notice-failure-color); }
|
||||
|
|
|
@ -248,6 +248,7 @@ will throw an error.
|
|||
--notice-lts-color: #2a681d;
|
||||
--notice-lts-reqd-color: #876327;
|
||||
--notice-success-color: #2a681d;
|
||||
--notice-failure-color: #7a2020;
|
||||
--optionbar-border-color: #333;
|
||||
--tab-background: #181818;
|
||||
--tab-border-color: #3f3f3f;
|
||||
|
|
|
@ -266,6 +266,7 @@ will throw an error.
|
|||
--notice-lts-color: #43a52f;
|
||||
--notice-lts-reqd-color: #aa7d30;
|
||||
--notice-success-color: #43a52f;
|
||||
--notice-failure-color: #b42222;
|
||||
--optionbar-border-color: #ccc;
|
||||
--tab-background: #fff;
|
||||
--tab-border-color: #d8d8d8;
|
||||
|
|
|
@ -8,6 +8,12 @@ import (
|
|||
|
||||
"git.handmade.network/hmn/hmn/src/auth"
|
||||
"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"
|
||||
"github.com/jackc/pgx/v4"
|
||||
"github.com/spf13/cobra"
|
||||
|
@ -57,6 +63,68 @@ func init() {
|
|||
fmt.Printf("Successfully updated password for '%s'\n", canonicalUsername)
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -11,8 +11,12 @@ import (
|
|||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
|
@ -187,3 +191,47 @@ func UpdatePassword(ctx context.Context, conn *pgxpool.Pool, username string, hp
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -94,6 +94,7 @@ func NewSessionCookie(session *models.Session) *http.Cookie {
|
|||
Value: session.ID,
|
||||
|
||||
Domain: config.Config.Auth.CookieDomain,
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(sessionDuration),
|
||||
|
||||
Secure: config.Config.Auth.CookieSecure,
|
||||
|
|
|
@ -25,6 +25,14 @@ var Config = HMNConfig{
|
|||
CookieDomain: ".handmade.local",
|
||||
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{
|
||||
AssetsSpacesKey: "",
|
||||
AssetsSpacesSecret: "",
|
||||
|
|
|
@ -23,6 +23,7 @@ type HMNConfig struct {
|
|||
LogLevel zerolog.Level
|
||||
Postgres PostgresConfig
|
||||
Auth AuthConfig
|
||||
Email EmailConfig
|
||||
DigitalOcean DigitalOceanConfig
|
||||
}
|
||||
|
||||
|
@ -52,6 +53,15 @@ type DigitalOceanConfig struct {
|
|||
AssetsPublicUrlRoot string
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
ServerAddress string
|
||||
ServerPort int
|
||||
FromAddress string
|
||||
FromAddressPassword string
|
||||
FromName string
|
||||
OverrideRecipientEmail 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)
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
|
@ -28,6 +28,7 @@ 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")
|
||||
}
|
||||
|
@ -70,6 +71,14 @@ func TestRegister(t *testing.T) {
|
|||
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) {
|
||||
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
|
||||
AssertRegexMatch(t, BuildAbout(), RegexAbout, nil)
|
||||
|
@ -204,12 +213,12 @@ func TestBlog(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBlogThread(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, "", 1), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, "", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://", 2), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
|
||||
AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 2, 123), RegexBlogThread, map[string]string{"threadid": "1", "page": "2"})
|
||||
AssertRegexNoMatch(t, BuildBlogThread("", 1, "", 2), RegexBlog)
|
||||
AssertSubdomain(t, BuildBlogThread("hero", 1, "", 1), "hero")
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, ""), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, BuildBlogThread("", 1, "title/bla/http://"), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexMatch(t, BuildBlogThreadWithPostHash("", 1, "title/bla/http://", 123), RegexBlogThread, map[string]string{"threadid": "1"})
|
||||
AssertRegexNoMatch(t, BuildBlogThread("", 1, ""), RegexBlog)
|
||||
AssertSubdomain(t, BuildBlogThread("hero", 1, ""), "hero")
|
||||
}
|
||||
|
||||
func TestBlogPost(t *testing.T) {
|
||||
|
@ -236,12 +245,6 @@ func TestBlogPostReply(t *testing.T) {
|
|||
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) {
|
||||
AssertRegexMatch(t, BuildLibrary(""), RegexLibrary, nil)
|
||||
AssertSubdomain(t, BuildLibrary("hero"), "hero")
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
var RegexOldHome = regexp.MustCompile("^/home$")
|
||||
var RegexHomepage = regexp.MustCompile("^/$")
|
||||
|
||||
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)
|
||||
|
@ -57,11 +62,11 @@ func BuildLoginAction(redirectTo string) string {
|
|||
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
|
||||
}
|
||||
|
||||
var RegexLoginPage = regexp.MustCompile("^/_login$")
|
||||
var RegexLoginPage = regexp.MustCompile("^/login$")
|
||||
|
||||
func BuildLoginPage(redirectTo string) string {
|
||||
defer CatchPanic()
|
||||
return Url("/_login", []Q{{Name: "redirect", Value: redirectTo}})
|
||||
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
|
||||
}
|
||||
|
||||
var RegexLogoutAction = regexp.MustCompile("^/logout$")
|
||||
|
@ -74,11 +79,34 @@ func BuildLogoutAction(redir string) string {
|
|||
return Url("/logout", []Q{{"redirect", redir}})
|
||||
}
|
||||
|
||||
var RegexRegister = regexp.MustCompile("^/_register$")
|
||||
var RegexRegister = regexp.MustCompile("^/register$")
|
||||
|
||||
func BuildRegister() string {
|
||||
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 {
|
||||
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 ""
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
@ -352,7 +352,7 @@ func SeedFromFile(seedFile string, afterMigration types.MigrationVersion) {
|
|||
Migrate(afterMigration)
|
||||
|
||||
fmt.Println("Executing seed...")
|
||||
cmd := exec.Command("psql",
|
||||
cmd := exec.Command("pg_restore",
|
||||
"--single-transaction",
|
||||
"--dbname",
|
||||
config.Config.Postgres.DbName,
|
||||
|
@ -361,7 +361,7 @@ func SeedFromFile(seedFile string, afterMigration types.MigrationVersion) {
|
|||
"--username",
|
||||
config.Config.Postgres.User,
|
||||
"--password",
|
||||
"-f",
|
||||
"--data-only",
|
||||
seedFile,
|
||||
)
|
||||
fmt.Println("Running command:", cmd)
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -7,6 +7,14 @@ import (
|
|||
|
||||
var UserType = reflect.TypeOf(User{})
|
||||
|
||||
type UserStatus int
|
||||
|
||||
const (
|
||||
UserStatusInactive UserStatus = iota + 1
|
||||
UserStatusActive
|
||||
UserStatusBanned
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id"`
|
||||
|
||||
|
@ -17,8 +25,8 @@ type User struct {
|
|||
DateJoined time.Time `db:"date_joined"`
|
||||
LastLogin *time.Time `db:"last_login"`
|
||||
|
||||
IsStaff bool `db:"is_staff"`
|
||||
IsActive bool `db:"is_active"`
|
||||
IsStaff bool `db:"is_staff"`
|
||||
Status UserStatus `db:"status"`
|
||||
|
||||
Name string `db:"name"`
|
||||
Bio string `db:"bio"`
|
||||
|
@ -26,10 +34,8 @@ type User struct {
|
|||
Signature string `db:"signature"`
|
||||
Avatar *string `db:"avatar"` // TODO: Image field stuff?
|
||||
|
||||
DarkTheme bool `db:"darktheme"`
|
||||
Timezone string `db:"timezone"`
|
||||
ProfileColor1 string `db:"color_1"`
|
||||
ProfileColor2 string `db:"color_2"`
|
||||
DarkTheme bool `db:"darktheme"`
|
||||
Timezone string `db:"timezone"`
|
||||
|
||||
ShowEmail bool `db:"showemail"`
|
||||
CanEditLibrary bool `db:"edit_library"`
|
||||
|
@ -39,3 +45,7 @@ type User struct {
|
|||
|
||||
MarkedAllReadAt time.Time `db:"marked_all_read_at"`
|
||||
}
|
||||
|
||||
func (u *User) IsActive() bool {
|
||||
return u.Status == UserStatusActive
|
||||
}
|
||||
|
|
|
@ -2,7 +2,10 @@ package perf
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type RequestPerf struct {
|
||||
|
@ -132,3 +135,15 @@ func (perfCollector *PerfCollector) GetPerfCopy() *PerfStorage {
|
|||
perfStorageCopy := <-resultChan
|
||||
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))
|
||||
}
|
||||
|
|
|
@ -33,3 +33,7 @@
|
|||
.notice-success {
|
||||
@include usevar(background-color, notice-success-color);
|
||||
}
|
||||
|
||||
.notice-failure {
|
||||
@include usevar(background-color, notice-failure-color);
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ $vars: (
|
|||
notice-lts-color: #2a681d,
|
||||
notice-lts-reqd-color: #876327,
|
||||
notice-success-color: #2a681d,
|
||||
notice-failure-color: #7a2020,
|
||||
|
||||
optionbar-border-color: #333,
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ $vars: (
|
|||
notice-lts-color: #43a52f,
|
||||
notice-lts-reqd-color: #aa7d30,
|
||||
notice-success-color: #43a52f,
|
||||
notice-failure-color: #b42222,
|
||||
|
||||
optionbar-border-color: #ccc,
|
||||
|
||||
|
|
|
@ -160,10 +160,8 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
|||
AvatarUrl: UserAvatarUrl(u, currentTheme),
|
||||
ProfileUrl: hmnurl.BuildUserProfile(u.Username),
|
||||
|
||||
DarkTheme: u.DarkTheme,
|
||||
Timezone: u.Timezone,
|
||||
ProfileColor1: u.ProfileColor1,
|
||||
ProfileColor2: u.ProfileColor2,
|
||||
DarkTheme: u.DarkTheme,
|
||||
Timezone: u.Timezone,
|
||||
|
||||
CanEditLibrary: u.CanEditLibrary,
|
||||
DiscordSaveShowcase: u.DiscordSaveShowcase,
|
||||
|
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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>
|
|
@ -80,6 +80,7 @@
|
|||
<body class="{{ join " " .BodyClasses }}">
|
||||
<div class="content mw-site ph3-m ph4-l">
|
||||
{{ template "header.html" . }}
|
||||
{{ template "notices.html" .Notices }}
|
||||
{{ with .Breadcrumbs }}
|
||||
<div class="tc tl-ns ph2 ph0-ns pb2 pb0-ns">
|
||||
{{ range $i, $e := . -}}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
{{ template "notices.html" .Notices }}
|
||||
<form id="podcast_form" method="POST" enctype="multipart/form-data">
|
||||
{{ csrftoken .Session }}
|
||||
<input class="b w-100 mb1" type="text" name="title" required placeholder="Podcast title..." value="{{ .Podcast.Title }}" />
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
{{ template "notices.html" .Notices }}
|
||||
<h1>{{ if .IsEdit }}Edit{{ else }}New{{ end }} Episode</h1>
|
||||
<form method="POST">
|
||||
{{ csrftoken .Session }}
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
{{ define "content" }}
|
||||
<div class="flex flex-column flex-row-l">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
{{ template "notices.html" .Notices }}
|
||||
{{ with .Screenshots }}
|
||||
<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">
|
||||
|
|
|
@ -13,6 +13,7 @@ type BaseData struct {
|
|||
Theme string
|
||||
BodyClasses []string
|
||||
Breadcrumbs []Breadcrumb
|
||||
Notices []Notice
|
||||
|
||||
CurrentUrl string
|
||||
LoginPageUrl string
|
||||
|
@ -128,10 +129,8 @@ type User struct {
|
|||
AvatarUrl string
|
||||
ProfileUrl string
|
||||
|
||||
DarkTheme bool
|
||||
Timezone string
|
||||
ProfileColor1 string
|
||||
ProfileColor2 string
|
||||
DarkTheme bool
|
||||
Timezone string
|
||||
|
||||
CanEditLibrary bool
|
||||
DiscordSaveShowcase bool
|
||||
|
@ -172,6 +171,7 @@ type PodcastEpisode struct {
|
|||
Duration int
|
||||
}
|
||||
|
||||
// NOTE(asaf): See /src/rawdata/scss/_notices.scss for a list of classes.
|
||||
type Notice struct {
|
||||
Content template.HTML
|
||||
Class string
|
||||
|
@ -296,3 +296,10 @@ type Pagination struct {
|
|||
PreviousUrl string
|
||||
NextUrl string
|
||||
}
|
||||
|
||||
type EmailBaseData struct {
|
||||
To template.HTML
|
||||
From template.HTML
|
||||
Subject template.HTML
|
||||
Separator template.HTML
|
||||
}
|
||||
|
|
|
@ -317,6 +317,10 @@ 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,
|
||||
|
|
|
@ -2,17 +2,55 @@ package website
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/auth"
|
||||
"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/models"
|
||||
"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 {
|
||||
// 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()
|
||||
if err != nil {
|
||||
|
@ -22,7 +60,7 @@ func Login(c *RequestContext) ResponseData {
|
|||
username := form.Get("username")
|
||||
password := form.Get("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")
|
||||
|
@ -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)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||
return ResponseData{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
}
|
||||
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
|
||||
} else {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
|
||||
}
|
||||
}
|
||||
user := userRow.(*models.User)
|
||||
|
||||
hashed, err := auth.ParsePasswordString(user.Password)
|
||||
success, err := tryLogin(c, user, password)
|
||||
|
||||
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 err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check password against hash"))
|
||||
}
|
||||
|
||||
if passwordsMatch {
|
||||
// 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
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
} 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 {
|
||||
|
@ -101,3 +136,452 @@ func Logout(c *RequestContext) ResponseData {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -74,7 +74,6 @@ func PodcastIndex(c *RequestContext) ResponseData {
|
|||
type PodcastEditData struct {
|
||||
templates.BaseData
|
||||
Podcast templates.Podcast
|
||||
Notices []templates.Notice
|
||||
}
|
||||
|
||||
func PodcastEdit(c *RequestContext) ResponseData {
|
||||
|
@ -102,7 +101,7 @@ func PodcastEdit(c *RequestContext) ResponseData {
|
|||
|
||||
success := c.URL().Query().Get("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
|
||||
|
@ -312,7 +311,6 @@ type PodcastEpisodeEditData struct {
|
|||
EpisodeNumber int
|
||||
CurrentFile string
|
||||
EpisodeFiles []string
|
||||
Notices []templates.Notice
|
||||
}
|
||||
|
||||
func PodcastEpisodeNew(c *RequestContext) ResponseData {
|
||||
|
@ -392,7 +390,7 @@ func PodcastEpisodeEdit(c *RequestContext) ResponseData {
|
|||
|
||||
success := c.URL().Query().Get("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
|
||||
|
|
|
@ -209,7 +209,6 @@ type ProjectHomepageData struct {
|
|||
templates.BaseData
|
||||
Project templates.Project
|
||||
Owners []templates.User
|
||||
Notices []templates.Notice
|
||||
Screenshots []string
|
||||
ProjectLinks []templates.Link
|
||||
Licenses []templates.Link
|
||||
|
@ -385,7 +384,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
Class: "hidden",
|
||||
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 {
|
||||
|
@ -413,7 +412,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
lifecycleNotice.Class = "lts"
|
||||
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() {
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
@ -142,6 +143,37 @@ func (c *RequestContext) FullUrl() 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) {
|
||||
err := c.Req.ParseForm()
|
||||
if err != nil {
|
||||
|
|
|
@ -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 {
|
||||
var res ResponseData
|
||||
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
|
||||
// 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.RegexAtomFeed, AtomFeed)
|
||||
mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
|
||||
|
@ -229,7 +238,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
|
|||
UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
|
||||
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
|
||||
RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||
RegisterUrl: hmnurl.BuildRegister(),
|
||||
HMNHomepageUrl: hmnurl.BuildHomepage(),
|
||||
ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
|
||||
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
|
||||
|
|
|
@ -40,6 +40,7 @@ var WebsiteCommand = &cobra.Command{
|
|||
|
||||
backgroundJobsDone := zipJobs(
|
||||
auth.PeriodicallyDeleteExpiredSessions(backgroundJobContext, conn),
|
||||
auth.PeriodicallyDeleteInactiveUsers(backgroundJobContext, conn),
|
||||
perfCollector.Done,
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in New Issue