Registration flow and email
This commit is contained in:
parent
038ee7e90e
commit
660f65ba95
|
@ -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); }
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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: "",
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
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")
|
||||||
|
|
|
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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{})
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 }}">
|
<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 := . -}}
|
||||||
|
|
|
@ -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 }}" />
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue