forked from mirrors/pronouns.cc
start email functions
This commit is contained in:
parent
34002e77d9
commit
283cc1681c
20 changed files with 351 additions and 24 deletions
|
@ -1,7 +1,11 @@
|
||||||
// Package common contains functions and types common to all (or most) packages.
|
// Package common contains functions and types common to all (or most) packages.
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import "unicode/utf8"
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
func StringLength(s *string) int {
|
func StringLength(s *string) int {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
|
@ -9,3 +13,14 @@ func StringLength(s *string) int {
|
||||||
}
|
}
|
||||||
return utf8.RuneCountInString(*s)
|
return utf8.RuneCountInString(*s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RandBase64 returns a string of random bytes encoded in raw base 64.
|
||||||
|
func RandBase64(size int) string {
|
||||||
|
b := make([]byte, size)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
|
@ -37,3 +37,15 @@ func (id FlagID) Increment() uint16 { return Snowflake(id).Increment() }
|
||||||
|
|
||||||
func (id FlagID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
func (id FlagID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
||||||
func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
||||||
|
|
||||||
|
type EmailID Snowflake
|
||||||
|
|
||||||
|
func (id EmailID) String() string { return Snowflake(id).String() }
|
||||||
|
func (id EmailID) Time() time.Time { return Snowflake(id).Time() }
|
||||||
|
func (id EmailID) IsValid() bool { return Snowflake(id).IsValid() }
|
||||||
|
func (id EmailID) Worker() uint8 { return Snowflake(id).Worker() }
|
||||||
|
func (id EmailID) PID() uint8 { return Snowflake(id).PID() }
|
||||||
|
func (id EmailID) Increment() uint16 { return Snowflake(id).Increment() }
|
||||||
|
|
||||||
|
func (id EmailID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
||||||
|
func (id *EmailID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
||||||
|
|
93
backend/db/email.go
Normal file
93
backend/db/email.go
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserEmail struct {
|
||||||
|
ID common.EmailID
|
||||||
|
UserID common.UserID
|
||||||
|
EmailAddress string
|
||||||
|
Confirmed bool
|
||||||
|
|
||||||
|
ConfirmationToken *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UserEmails(ctx context.Context, userID common.UserID) (es []UserEmail, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("user_emails").Where("user_id = ?", userID).OrderBy("id").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &es, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotNull(es), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserByEmail gets a user by their email address.
|
||||||
|
// The email address must be confirmed.
|
||||||
|
func (db *DB) UserByEmail(ctx context.Context, email string) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("users.*").
|
||||||
|
From("user_emails").
|
||||||
|
Join("users ON user_emails.user_id = users.snowflake_id").
|
||||||
|
Where("email_address = ?", email).
|
||||||
|
Where("confirmed = ?", true).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return u, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailExists returns whether an email address already exists. It does not need to be comfirmed.
|
||||||
|
func (db *DB) EmailExists(ctx context.Context, email string) (exists bool, err error) {
|
||||||
|
err = db.QueryRow(ctx, "select exists(SELECT * FROM user_emails WHERE email = $1)", email).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrEmailInUse = errors.Sentinel("email already in use")
|
||||||
|
|
||||||
|
// AddEmail adds a new email to the database, and generates a confirmation token for it.
|
||||||
|
func (db *DB) AddEmail(ctx context.Context, tx pgx.Tx, userID common.UserID, email string) (e UserEmail, err error) {
|
||||||
|
sql, args, err := sq.Insert("user_emails").SetMap(map[string]any{
|
||||||
|
"id": common.GenerateID(),
|
||||||
|
"user_id": userID,
|
||||||
|
"email_address": email,
|
||||||
|
"confirmed": false,
|
||||||
|
"confirmation_token": common.RandBase64(48),
|
||||||
|
}).Suffix("RETURNING *").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return e, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &e, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
pge := &pgconn.PgError{}
|
||||||
|
if errors.As(err, &pge) {
|
||||||
|
// unique constraint violation
|
||||||
|
if pge.Code == uniqueViolation {
|
||||||
|
return e, ErrEmailInUse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
|
@ -56,6 +56,7 @@ type User struct {
|
||||||
LastSIDReroll time.Time `db:"last_sid_reroll"`
|
LastSIDReroll time.Time `db:"last_sid_reroll"`
|
||||||
Timezone *string
|
Timezone *string
|
||||||
Settings UserSettings
|
Settings UserSettings
|
||||||
|
Password *string
|
||||||
|
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
SelfDelete *bool
|
SelfDelete *bool
|
||||||
|
|
|
@ -6,11 +6,10 @@ import (
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user"
|
||||||
|
auth2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/auth"
|
||||||
user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user"
|
user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
_ "embed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// mountRoutes mounts all API routes on the server's router.
|
// mountRoutes mounts all API routes on the server's router.
|
||||||
|
@ -26,5 +25,6 @@ func mountRoutes(s *server.Server) {
|
||||||
|
|
||||||
s.Router.Route("/v2", func(r chi.Router) {
|
s.Router.Route("/v2", func(r chi.Router) {
|
||||||
user2.Mount(s, r)
|
user2.Mount(s, r)
|
||||||
|
auth2.Mount(s, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
@ -141,7 +142,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Discord info in Redis
|
// no user found, so save a ticket + save their Discord info in Redis
|
||||||
ticket := RandBase64(32)
|
ticket := common.RandBase64(32)
|
||||||
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
|
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
|
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
@ -162,7 +163,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Mastodon info in Redis
|
// no user found, so save a ticket + save their Mastodon info in Redis
|
||||||
ticket := RandBase64(32)
|
ticket := common.RandBase64(32)
|
||||||
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
|
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
|
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
@ -141,7 +142,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Misskey info in Redis
|
// no user found, so save a ticket + save their Misskey info in Redis
|
||||||
ticket := RandBase64(32)
|
ticket := common.RandBase64(32)
|
||||||
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
|
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
|
log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
@ -160,7 +161,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Google info in Redis
|
// no user found, so save a ticket + save their Google info in Redis
|
||||||
ticket := RandBase64(32)
|
ticket := common.RandBase64(32)
|
||||||
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
|
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting Google user for ticket %q: %v", ticket, err)
|
log.Errorf("setting Google user for ticket %q: %v", ticket, err)
|
||||||
|
|
|
@ -2,9 +2,8 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
"github.com/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,7 +13,7 @@ const numStates = "1000"
|
||||||
|
|
||||||
// setCSRFState generates a random string to use as state, then stores that in Redis.
|
// setCSRFState generates a random string to use as state, then stores that in Redis.
|
||||||
func (s *Server) setCSRFState(ctx context.Context) (string, error) {
|
func (s *Server) setCSRFState(ctx context.Context) (string, error) {
|
||||||
state := RandBase64(32)
|
state := common.RandBase64(32)
|
||||||
|
|
||||||
err := s.DB.MultiCmd(ctx,
|
err := s.DB.MultiCmd(ctx,
|
||||||
radix.Cmd(nil, "LPUSH", "csrf", state),
|
radix.Cmd(nil, "LPUSH", "csrf", state),
|
||||||
|
@ -32,14 +31,3 @@ func (s *Server) validateCSRFState(ctx context.Context, state string) (matched b
|
||||||
}
|
}
|
||||||
return num > 0, nil
|
return num > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RandBase64 returns a string of random bytes encoded in raw base 64.
|
|
||||||
func RandBase64(size int) string {
|
|
||||||
b := make([]byte, size)
|
|
||||||
_, err := rand.Read(b)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
@ -193,7 +194,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found, so save a ticket + save their Tumblr info in Redis
|
// no user found, so save a ticket + save their Tumblr info in Redis
|
||||||
ticket := RandBase64(32)
|
ticket := common.RandBase64(32)
|
||||||
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
|
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
|
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
|
||||||
|
|
|
@ -13,10 +13,17 @@ import (
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
*server.Server
|
*server.Server
|
||||||
|
|
||||||
|
requireInvite bool
|
||||||
|
emailSignup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Mount(srv *server.Server, r chi.Router) {
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
s := &Server{Server: srv}
|
s := &Server{
|
||||||
|
Server: srv,
|
||||||
|
requireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
||||||
|
emailSignup: os.Getenv("EMAIL_SMTP_ADDRESS") != "",
|
||||||
|
}
|
||||||
|
|
||||||
r.Get("/meta", server.WrapHandler(s.meta))
|
r.Get("/meta", server.WrapHandler(s.meta))
|
||||||
}
|
}
|
||||||
|
@ -27,6 +34,7 @@ type MetaResponse struct {
|
||||||
Users MetaUsers `json:"users"`
|
Users MetaUsers `json:"users"`
|
||||||
Members int64 `json:"members"`
|
Members int64 `json:"members"`
|
||||||
RequireInvite bool `json:"require_invite"`
|
RequireInvite bool `json:"require_invite"`
|
||||||
|
EmailSignup bool `json:"email_signup"`
|
||||||
Notice *MetaNotice `json:"notice"`
|
Notice *MetaNotice `json:"notice"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,7 +77,8 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
|
||||||
ActiveDay: activeDay,
|
ActiveDay: activeDay,
|
||||||
},
|
},
|
||||||
Members: numMembers,
|
Members: numMembers,
|
||||||
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
RequireInvite: s.requireInvite,
|
||||||
|
EmailSignup: s.emailSignup,
|
||||||
Notice: notice,
|
Notice: notice,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
|
|
56
backend/routes/v2/auth/email_signup.go
Normal file
56
backend/routes/v2/auth/email_signup.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type postEmailSignupRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) postEmailSignup(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
var req postEmailSignupRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(req.Email, "@") {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Email seems to be invalid"}
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket := common.RandBase64(48)
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "SET", emailSignupTicketKey(ticket), req.Email, "EX", "3600"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setting email signup key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the email address already exists, pretend we sent an email and return
|
||||||
|
// to prevent people from discovering valid email addresses
|
||||||
|
exists, err := s.DB.EmailExists(ctx, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "checking if email exists")
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.SendEmail(req.Email, "Confirm your email address", "signup", map[string]any{
|
||||||
|
"ticket": ticket,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailSignupTicketKey(ticket string) string {
|
||||||
|
return "email-signup:" + ticket
|
||||||
|
}
|
55
backend/routes/v2/auth/get_emails.go
Normal file
55
backend/routes/v2/auth/get_emails.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailResponse struct {
|
||||||
|
ID common.EmailID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Confirmed bool `json:"confirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbEmailToResponse(e db.UserEmail) EmailResponse {
|
||||||
|
return EmailResponse{
|
||||||
|
ID: e.ID,
|
||||||
|
Email: e.EmailAddress,
|
||||||
|
Confirmed: e.Confirmed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getEmails(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
// this should never fail
|
||||||
|
log.Errorf("getting user: %v", err)
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
emails, err := s.DB.UserEmails(ctx, u.SnowflakeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user emails: %v", err)
|
||||||
|
return errors.Wrap(err, "getting user emails")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]EmailResponse, len(emails))
|
||||||
|
for i := range emails {
|
||||||
|
out[i] = dbEmailToResponse(emails[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, out)
|
||||||
|
return nil
|
||||||
|
}
|
51
backend/routes/v2/auth/routes.go
Normal file
51
backend/routes/v2/auth/routes.go
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jordan-wright/email"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
*server.Server
|
||||||
|
|
||||||
|
emailPool *email.Pool
|
||||||
|
emailAddress string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
|
s := &Server{
|
||||||
|
Server: srv,
|
||||||
|
emailAddress: os.Getenv("EMAIL_ADDRESS"), // The address used for sending email
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only mount email routes if email is set up
|
||||||
|
if os.Getenv("EMAIL_SMTP_ADDRESS") != "" {
|
||||||
|
// This function will never return an error
|
||||||
|
s.emailPool, _ = email.NewPool(
|
||||||
|
os.Getenv("EMAIL_SMTP_ADDRESS"), // This should be the SMTP server host, like "example.com:587"
|
||||||
|
4, smtp.PlainAuth("",
|
||||||
|
os.Getenv("EMAIL_USERNAME"), // This should be the account name, like "noreply@pronouns.cc"
|
||||||
|
os.Getenv("EMAIL_PASSWORD"), // This should be the account pass, like "6GzW3dY6"
|
||||||
|
os.Getenv("EMAIL_HOST"), // This should be the email host (seems to be SMTP server without the port?)
|
||||||
|
))
|
||||||
|
|
||||||
|
r.Route("/auth/email", func(r chi.Router) {
|
||||||
|
r.With(server.MustAuth).Get("/", server.WrapHandler(s.getEmails)) // List existing email addresses for account
|
||||||
|
r.With(server.MustAuth).Post("/", nil) // Add/update email to existing account, { email }
|
||||||
|
r.With(server.MustAuth).Delete("/{id}", nil) // Remove existing email from account, <no body>
|
||||||
|
|
||||||
|
r.Post("/login", nil) // Log in to account, { username, password }
|
||||||
|
r.Post("/signup", server.WrapHandler(s.postEmailSignup)) // Create account, { email }
|
||||||
|
r.Post("/signup/confirm", nil) // Create account, { ticket, username, password }
|
||||||
|
r.Post("/confirm", nil) // Confirm email address, { ticket }
|
||||||
|
|
||||||
|
r.Patch("/password", nil) // Update password
|
||||||
|
r.Post("/password/forgot", nil) // Forgot/reset password, { email }
|
||||||
|
r.Post("/password/confirm", nil) // Confirm resetting password, { ticket, new_password }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
18
backend/routes/v2/auth/send_email.go
Normal file
18
backend/routes/v2/auth/send_email.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"github.com/jordan-wright/email"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) SendEmail(to, title, template string, data map[string]any) {
|
||||||
|
e := email.NewEmail()
|
||||||
|
e.From = s.emailAddress
|
||||||
|
e.To = []string{to}
|
||||||
|
|
||||||
|
err := s.emailPool.Send(e, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("sending email: %v", err)
|
||||||
|
}
|
||||||
|
}
|
0
backend/routes/v2/auth/templates/signup.txt
Normal file
0
backend/routes/v2/auth/templates/signup.txt
Normal file
1
go.mod
1
go.mod
|
@ -19,6 +19,7 @@ require (
|
||||||
github.com/google/uuid v1.4.0
|
github.com/google/uuid v1.4.0
|
||||||
github.com/jackc/pgx/v5 v5.4.3
|
github.com/jackc/pgx/v5 v5.4.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
|
||||||
github.com/mediocregopher/radix/v4 v4.1.4
|
github.com/mediocregopher/radix/v4 v4.1.4
|
||||||
github.com/minio/minio-go/v7 v7.0.63
|
github.com/minio/minio-go/v7 v7.0.63
|
||||||
github.com/prometheus/client_golang v1.17.0
|
github.com/prometheus/client_golang v1.17.0
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -109,6 +109,8 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
||||||
|
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw=
|
github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw=
|
||||||
|
|
21
scripts/migrate/023_email.sql
Normal file
21
scripts/migrate/023_email.sql
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
-- 2023-10-28: Add email/password login
|
||||||
|
|
||||||
|
-- +migrate Up
|
||||||
|
|
||||||
|
alter table users add column password bytea null;
|
||||||
|
alter table users add column salt bytea null;
|
||||||
|
|
||||||
|
create table user_emails (
|
||||||
|
id bigint primary key,
|
||||||
|
user_id bigint not null references users (snowflake_id) on delete cascade,
|
||||||
|
email_address text not null unique,
|
||||||
|
confirmed boolean not null default false,
|
||||||
|
confirmation_token text null unique
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +migrate Down
|
||||||
|
|
||||||
|
alter table users drop column password;
|
||||||
|
alter table users drop column salt;
|
||||||
|
|
||||||
|
drop table user_emails;
|
Loading…
Reference in a new issue