New registration flow

This commit is contained in:
m15o 2021-12-10 18:48:24 +01:00
parent 1c6d2a04c3
commit 44b2dfc6d1
22 changed files with 374 additions and 82 deletions

View file

@ -86,4 +86,8 @@ dd {
.radio { display: inline-block } .radio { display: inline-block }
.field { margin-bottom: 1rem; max-width: 500px; } .field { margin-bottom: 1rem; max-width: 500px; }
.field > label { margin-bottom: .25rem; } .field > label { margin-bottom: .25rem; }
.field > * { display: block; width: 100%; box-sizing: border-box; } .field > * { display: block; width: 100%; box-sizing: border-box; }
.info {
margin-bottom: 1em;
}

View file

@ -4,12 +4,16 @@ import "os"
type ( type (
Config struct { Config struct {
DatabaseURL string DatabaseURL string
SessionKey string SessionKey string
Env string Env string
CertFile string CertFile string
KeyFile string KeyFile string
AssetsDir string AssetsDir string
EmailUsername string
EmailPassword string
EmailHost string
EmailHostAddr string
} }
) )

View file

@ -8,15 +8,17 @@ import (
) )
type User struct { type User struct {
Name string Name string
Password string Password string
Hash []byte Hash []byte
Homepage string Homepage string
About string About string
Style string Picture string
Picture string Email string
Email string SignupEmail string
CreatedAt time.Time SignupMsg string
Active bool
CreatedAt time.Time
} }
func (u User) Validate() error { func (u User) Validate() error {

View file

@ -7,7 +7,7 @@ import (
"strconv" "strconv"
) )
const schemaVersion = 9 const schemaVersion = 11
func Migrate(db *sql.DB) { func Migrate(db *sql.DB) {
var currentVersion int var currentVersion int

View file

@ -25,6 +25,15 @@ create table statuses
created_at timestamp with time zone DEFAULT now() created_at timestamp with time zone DEFAULT now()
); );
`, `,
"schema_version_10": `alter table users
add column signup_email TEXT not null DEFAULT '',
add column signup_msg TEXT not null DEFAULT '',
add column active bool default false;
-- All existing users are active
update users set active=true;`,
"schema_version_11": `ALTER TABLE statuses
ALTER COLUMN face TYPE VARCHAR(2);`,
"schema_version_2": `alter table users "schema_version_2": `alter table users
add column status_id int references statuses(id); add column status_id int references statuses(id);
`, `,

View file

@ -0,0 +1,7 @@
alter table users
add column signup_email TEXT not null DEFAULT '',
add column signup_msg TEXT not null DEFAULT '',
add column active bool default false;
-- All existing users are active
update users set active=true;

View file

@ -0,0 +1,2 @@
ALTER TABLE statuses
ALTER COLUMN face TYPE VARCHAR(2);

View file

@ -94,6 +94,24 @@ func (s *Storage) StatusByUsername(user string, perPage int, page int64) ([]mode
return statuses, false, err return statuses, false, err
} }
func (s *Storage) StatusFeed() ([]model.Status, error) {
rows, err := s.db.Query(statusQueryBuilder{
limit: strconv.Itoa(20),
}.build())
if err != nil {
return nil, err
}
var statuses []model.Status
for rows.Next() {
post, err := s.populateStatus(rows)
if err != nil {
return nil, err
}
statuses = append(statuses, post)
}
return statuses, err
}
func (s *Storage) Statuses(page int64, perPage int) ([]model.Status, bool, error) { func (s *Storage) Statuses(page int64, perPage int) ([]model.Status, bool, error) {
rows, err := s.db.Query(statusQueryBuilder{ rows, err := s.db.Query(statusQueryBuilder{
limit: strconv.Itoa(perPage + 1), limit: strconv.Itoa(perPage + 1),

View file

@ -1,13 +1,14 @@
package storage package storage
import ( import (
"errors"
"status/model" "status/model"
) )
const queryFindName = `SELECT name, hash, created_at, homepage, about, picture, email FROM users WHERE name=lower($1);` const queryFindName = `SELECT name, hash, created_at, homepage, about, picture, email, active, signup_email FROM users WHERE name=lower($1);`
func (s *Storage) queryUser(q string, params ...interface{}) (user model.User, err error) { func (s *Storage) queryUser(q string, params ...interface{}) (user model.User, err error) {
err = s.db.QueryRow(q, params...).Scan(&user.Name, &user.Hash, &user.CreatedAt, &user.Homepage, &user.About, &user.Picture, &user.Email) err = s.db.QueryRow(q, params...).Scan(&user.Name, &user.Hash, &user.CreatedAt, &user.Homepage, &user.About, &user.Picture, &user.Email, &user.Active, &user.SignupEmail)
return return
} }
@ -17,7 +18,10 @@ func (s *Storage) VerifyUser(user model.User) (model.User, error) {
return u, err return u, err
} }
if err := user.CompareHashToPassword(u.Hash); err != nil { if err := user.CompareHashToPassword(u.Hash); err != nil {
return u, err return u, errors.New("incorrect password")
}
if !u.Active {
return u, errors.New("user not active")
} }
return u, nil return u, nil
} }
@ -37,12 +41,12 @@ func (s *Storage) CreateUser(user model.User) error {
if err != nil { if err != nil {
return err return err
} }
insertUser := `INSERT INTO users (name, hash) VALUES (lower($1), $2)` insertUser := `INSERT INTO users (name, hash, email, signup_email, signup_msg) VALUES (lower($1), $2, $3, $4, $5)`
statement, err := s.db.Prepare(insertUser) statement, err := s.db.Prepare(insertUser)
if err != nil { if err != nil {
return err return err
} }
_, err = statement.Exec(user.Name, hash) _, err = statement.Exec(user.Name, hash, user.Email, user.SignupEmail, user.SignupMsg)
return err return err
} }
@ -63,13 +67,34 @@ func (s *Storage) Users() ([]string, error) {
return users, nil return users, nil
} }
func (s *Storage) DeleteUser(username string) error { func (s *Storage) InactiveUsers() ([]model.User, error) {
stmt, err := s.db.Prepare(`DELETE from status WHERE author = $1;`) rows, err := s.db.Query("select name, signup_email, signup_msg from users where active is false")
if err != nil {
return nil, err
}
var users []model.User
for rows.Next() {
var user model.User
err := rows.Scan(&user.Name, &user.SignupEmail, &user.SignupMsg)
if err != nil {
return users, err
}
users = append(users, user)
}
return users, nil
}
func (s *Storage) ActivateUser(name string) error {
stmt, err := s.db.Prepare(`UPDATE users SET active = true WHERE name = $1;`)
if err != nil { if err != nil {
return err return err
} }
_, err = stmt.Exec(username) _, err = stmt.Exec(name)
stmt, err = s.db.Prepare(`DELETE from users WHERE name = $1;`) return err
}
func (s *Storage) DeleteUser(username string) error {
stmt, err := s.db.Prepare(`DELETE from users WHERE name = $1;`)
if err != nil { if err != nil {
return err return err
} }

75
web/handler/admin_show.go Normal file
View file

@ -0,0 +1,75 @@
package handler
import (
"errors"
"fmt"
"net/http"
)
func (h *Handler) showAdminView(w http.ResponseWriter, r *http.Request) {
protectClickJacking(w)
username, err := h.getUser(r)
if err != nil {
unauthorized(w)
return
}
if username != "m15o" {
unauthorized(w)
return
}
users, err := h.storage.InactiveUsers()
if err != nil {
serverError(w, err)
return
}
h.renderLayout(w, "admin", map[string]interface{}{
"inactive": users,
}, username)
}
func (h *Handler) activateUser(w http.ResponseWriter, r *http.Request) {
protectClickJacking(w)
username, err := h.getUser(r)
if err != nil {
unauthorized(w)
return
}
if username != "m15o" {
unauthorized(w)
return
}
name := r.URL.Query().Get("name")
if err := h.storage.ActivateUser(name); err != nil {
serverError(w, err)
return
}
http.Redirect(w, r, fmt.Sprintf("/admin"), http.StatusFound)
}
func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) {
protectClickJacking(w)
username, err := h.getUser(r)
if err != nil {
unauthorized(w)
return
}
if username != "m15o" {
unauthorized(w)
return
}
name := r.URL.Query().Get("name")
user, err := h.storage.UserByName(name)
if err != nil {
serverError(w, err)
return
}
if user.Active {
serverError(w, errors.New("user is active"))
return
}
if err := h.storage.DeleteUser(name); err != nil {
serverError(w, err)
return
}
http.Redirect(w, r, fmt.Sprintf("/admin"), http.StatusFound)
}

47
web/handler/feed_show.go Normal file
View file

@ -0,0 +1,47 @@
package handler
import (
"fmt"
"github.com/gorilla/feeds"
"net/http"
"time"
)
func (h *Handler) showFeedView(w http.ResponseWriter, r *http.Request) {
now := time.Now()
feed := &feeds.Feed{
Title: "status.cafe",
Link: &feeds.Link{Href: "https://status.cafe/"},
Author: &feeds.Author{Name: "status.cafe"},
Created: now,
}
statuses, err := h.storage.StatusFeed()
if err != nil {
serverError(w, err)
return
}
for _, status := range statuses {
if err != nil {
serverError(w, err)
return
}
feed.Items = append(feed.Items, &feeds.Item{
Title: fmt.Sprintf("%s %s %s", status.User, status.Face, truncate(status.Content, 50)),
Link: &feeds.Link{Href: fmt.Sprintf("https://status.cafe/users/%s/%d", status.User, status.Id)},
Author: &feeds.Author{Name: status.User},
Content: status.Content,
Created: status.CreatedAt,
})
}
atom, err := feed.ToAtom()
if err != nil {
serverError(w, err)
return
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/atom+xml")
w.Write([]byte(atom))
}

View file

@ -7,11 +7,14 @@ import (
) )
type RegisterForm struct { type RegisterForm struct {
Username string Username string
Password string Password string
Confirm string Email string
Key string ShowEmail bool
Error string Answer string
Confirm string
Key string
Error string
} }
func (f *RegisterForm) Validate() error { func (f *RegisterForm) Validate() error {
@ -33,9 +36,12 @@ func (f *RegisterForm) Validate() error {
func NewRegisterForm(r *http.Request) *RegisterForm { func NewRegisterForm(r *http.Request) *RegisterForm {
return &RegisterForm{ return &RegisterForm{
Username: r.FormValue("name"), Username: r.FormValue("name"),
Password: r.FormValue("password"), Password: r.FormValue("password"),
Confirm: r.FormValue("password-confirm"), Confirm: r.FormValue("password-confirm"),
Key: r.FormValue("key"), Email: r.FormValue("email"),
ShowEmail: r.FormValue("show-email") == "1",
Answer: r.FormValue("answer"),
Key: r.FormValue("key"),
} }
} }

View file

@ -52,11 +52,17 @@ func New(cfg *config.Config, sess *session.Session, data *storage.Storage) (http
sess: sess, sess: sess,
} }
h.initTpl() h.initTpl()
router.HandleFunc("/", h.showIndexView).Methods(http.MethodGet) router.HandleFunc("/", h.showIndexView).Methods(http.MethodGet)
router.HandleFunc("/login", h.showLoginView).Methods(http.MethodGet) router.HandleFunc("/login", h.showLoginView).Methods(http.MethodGet)
router.HandleFunc("/check-login", h.checkLogin).Methods(http.MethodPost) router.HandleFunc("/check-login", h.checkLogin).Methods(http.MethodPost)
router.HandleFunc("/register", h.handleRegister) router.HandleFunc("/register", h.handleRegister)
router.HandleFunc("/logout", h.logout).Methods(http.MethodGet) router.HandleFunc("/logout", h.logout).Methods(http.MethodGet)
router.HandleFunc("/feed.atom", h.showFeedView).Methods(http.MethodGet)
router.HandleFunc("/admin", h.showAdminView).Methods(http.MethodGet)
router.HandleFunc("/activate-user", h.activateUser).Methods(http.MethodGet)
router.HandleFunc("/delete-user", h.deleteUser).Methods(http.MethodGet)
router.HandleFunc("/settings", h.showSettingsView).Methods(http.MethodGet) router.HandleFunc("/settings", h.showSettingsView).Methods(http.MethodGet)
router.HandleFunc("/settings-update", h.updateSettings).Methods(http.MethodPost) router.HandleFunc("/settings-update", h.updateSettings).Methods(http.MethodPost)

View file

@ -3,6 +3,17 @@
package handler package handler
var TplMap = map[string]string{ var TplMap = map[string]string{
"admin": `{{ define "content" }}
<section>
<h1>Admin</h1>
{{ range .inactive }}
<div>
<div><b>{{ .Name }}</b> ({{ .SignupEmail }}) <a href="/activate-user?name={{ .Name }}">Activate</a> | <a href="/delete-user?name={{ .Name }}">Delete</a></div>
<p>{{ .SignupMsg }}</p>
</div>
{{ end }}
</section>
{{ end }}`,
"confirm_remove_status": `{{ define "content" }} "confirm_remove_status": `{{ define "content" }}
<section> <section>
Are you sure you you want to delete the following status? Are you sure you you want to delete the following status?
@ -149,6 +160,7 @@ var TplMap = map[string]string{
<li><a href="/about/status-updater">status updater</a> bookmarklet</li> <li><a href="/about/status-updater">status updater</a> bookmarklet</li>
<li><a href="/current-status">status widget</a> for your homepage</li> <li><a href="/current-status">status widget</a> for your homepage</li>
</ul> </ul>
<p><a href="/feed.atom">Subscribe via Atom</a></p>
{{ else }} {{ else }}
<h2>Welcome!</h2> <h2>Welcome!</h2>
<p>status.cafe is a place to share your current status.</p> <p>status.cafe is a place to share your current status.</p>
@ -246,26 +258,54 @@ var TplMap = map[string]string{
{{ end }}`, {{ end }}`,
"register": `{{ define "content" }} "register": `{{ define "content" }}
<section> <section>
<h1>Register</h1> <div class="cols">
{{ if .form.Error }} <div>
<p>{{ .form.Error }}</p> <h1>Register</h1>
{{ end }} <div class="info">
<form action="/register" method="post" class="auth-form"> <p>Registrations are manually approved to prevent spam and keep this little cafe a cool place to hang out.</p>
<div class="field"> <p>You should receive a confirmation within a few hours at the email you have provided. Make sure to enter a valid address!</p>
<label for="name">Username</label> </div>
<input type="text" id="name" name="name" autocomplete="off" required autofocus/>
</div> </div>
<div class="field"> <div>
<label for="password">Password</label> {{ if .form.Error }}
<input type="password" id="password" name="password" required/> <p>{{ .form.Error }}</p>
{{ end }}
<form action="/register" method="post" class="auth-form">
<div class="field">
<label for="name">Username</label>
<input type="text" id="name" name="name" autocomplete="off" required autofocus/>
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="off" required autofocus/>
</div>
<div class="field">
<label for="show-email">Show e-mail</label>
<input type="checkbox" name="show-email" value="1" id="show-email" style="width: inherit;">
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required/>
</div>
<div class="field">
<label for="password-confirm">Confirm password</label>
<input type="password" id="password-confirm" name="password-confirm" required/>
</div>
<div class="field">
<label for="answer">How did you discover status.cafe?</label>
<textarea id="answer" name="answer" required></textarea>
</div>
<input type="submit" value="Submit">
</form>
</div> </div>
<div class="field"> </div>
<label for="password-confirm">Confirm password</label> </section>
<input type="password" id="password-confirm" name="password-confirm" required/> {{ end }}`,
</div> "register-success": `{{ define "content" }}
<br> <section>
<input type="submit" value="Submit"> <h1>Thank you!</h1>
</form> <p>Thanks for registering, {{ .name }}!</p>
<p>You should receive a confirmation email on {{ .email }} as soon as your account is activated.</p>
</section> </section>
{{ end }}`, {{ end }}`,
"settings": `{{ define "content" }} "settings": `{{ define "content" }}

View file

@ -0,0 +1,11 @@
{{ define "content" }}
<section>
<h1>Admin</h1>
{{ range .inactive }}
<div>
<div><b>{{ .Name }}</b> ({{ .SignupEmail }}) <a href="/activate-user?name={{ .Name }}">Activate</a> | <a href="/delete-user?name={{ .Name }}">Delete</a></div>
<p>{{ .SignupMsg }}</p>
</div>
{{ end }}
</section>
{{ end }}

View file

@ -12,6 +12,7 @@
<li><a href="/about/status-updater">status updater</a> bookmarklet</li> <li><a href="/about/status-updater">status updater</a> bookmarklet</li>
<li><a href="/current-status">status widget</a> for your homepage</li> <li><a href="/current-status">status widget</a> for your homepage</li>
</ul> </ul>
<p><a href="/feed.atom">Subscribe via Atom</a></p>
{{ else }} {{ else }}
<h2>Welcome!</h2> <h2>Welcome!</h2>
<p>status.cafe is a place to share your current status.</p> <p>status.cafe is a place to share your current status.</p>

View file

@ -0,0 +1,7 @@
{{ define "content" }}
<section>
<h1>Thank you!</h1>
<p>Thanks for registering, {{ .name }}!</p>
<p>You should receive a confirmation email on {{ .email }} as soon as your account is activated.</p>
</section>
{{ end }}

View file

@ -1,24 +1,45 @@
{{ define "content" }} {{ define "content" }}
<section> <section>
<h1>Register</h1> <div class="cols">
{{ if .form.Error }} <div>
<p>{{ .form.Error }}</p> <h1>Register</h1>
{{ end }} <div class="info">
<form action="/register" method="post" class="auth-form"> <p>Registrations are manually approved to prevent spam and keep this little cafe a cool place to hang out.</p>
<div class="field"> <p>You should receive a confirmation within a few hours at the email you have provided. Make sure to enter a valid address!</p>
<label for="name">Username</label> </div>
<input type="text" id="name" name="name" autocomplete="off" required autofocus/>
</div> </div>
<div class="field"> <div>
<label for="password">Password</label> {{ if .form.Error }}
<input type="password" id="password" name="password" required/> <p>{{ .form.Error }}</p>
{{ end }}
<form action="/register" method="post" class="auth-form">
<div class="field">
<label for="name">Username</label>
<input type="text" id="name" name="name" autocomplete="off" required autofocus/>
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="off" required autofocus/>
</div>
<div class="field">
<label for="show-email">Show e-mail</label>
<input type="checkbox" name="show-email" value="1" id="show-email" style="width: inherit;">
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required/>
</div>
<div class="field">
<label for="password-confirm">Confirm password</label>
<input type="password" id="password-confirm" name="password-confirm" required/>
</div>
<div class="field">
<label for="answer">How did you discover status.cafe?</label>
<textarea id="answer" name="answer" required></textarea>
</div>
<input type="submit" value="Submit">
</form>
</div> </div>
<div class="field"> </div>
<label for="password-confirm">Confirm password</label>
<input type="password" id="password-confirm" name="password-confirm" required/>
</div>
<br>
<input type="submit" value="Submit">
</form>
</section> </section>
{{ end }} {{ end }}

View file

@ -13,7 +13,7 @@ func (h *Handler) checkLogin(w http.ResponseWriter, r *http.Request) {
Password: f.Password, Password: f.Password,
}) })
if err != nil { if err != nil {
f.Error = "incorrect password" f.Error = err.Error()
h.renderLayout(w, "login", map[string]interface{}{ h.renderLayout(w, "login", map[string]interface{}{
"form": f, "form": f,
}, "") }, "")

View file

@ -93,8 +93,13 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
return return
} }
user := model.User{ user := model.User{
Name: f.Username, Name: f.Username,
Password: f.Password, Password: f.Password,
SignupEmail: f.Email,
SignupMsg: f.Answer,
}
if f.ShowEmail {
user.Email = f.Email
} }
if err := user.Validate(); err != nil { if err := user.Validate(); err != nil {
showError(err) showError(err)
@ -108,10 +113,9 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
showError(err) showError(err)
return return
} }
if err := h.sess.Save(r, w, r.FormValue("name")); err != nil { h.renderLayout(w, "register-success", map[string]interface{}{
serverError(w, err) "name": user.Name,
return "email": user.SignupEmail,
} }, "")
http.Redirect(w, r, "/", http.StatusFound)
} }
} }

View file

@ -82,7 +82,6 @@ func (h *Handler) showUserView(w http.ResponseWriter, r *http.Request) {
"about": template.HTML(user.About), "about": template.HTML(user.About),
"picture": user.Picture, "picture": user.Picture,
"email": user.Email, "email": user.Email,
"style": template.CSS(user.Style),
"showMore": showMore, "showMore": showMore,
"page": page, "page": page,
"next_page": page + 1, "next_page": page + 1,

View file

@ -52,8 +52,12 @@ func (s *Session) Get(r *http.Request) (string, error) {
if name == "" || !ok { if name == "" || !ok {
return "", errors.New("error extracting session") return "", errors.New("error extracting session")
} }
if ok := s.Storage.UserExists(name); !ok { u, err := s.Storage.UserByName(name)
return "", errors.New("user doesn't exit") if err != nil {
return "", errors.New("error getting user")
}
if !u.Active {
return "", errors.New("user not active")
} }
return name, nil return name, nil
} }