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 }
.field { margin-bottom: 1rem; max-width: 500px; }
.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 (
Config struct {
DatabaseURL string
SessionKey string
Env string
CertFile string
KeyFile string
AssetsDir string
DatabaseURL string
SessionKey string
Env string
CertFile string
KeyFile string
AssetsDir string
EmailUsername string
EmailPassword string
EmailHost string
EmailHostAddr string
}
)

View file

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

View file

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

View file

@ -25,6 +25,15 @@ create table statuses
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
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
}
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) {
rows, err := s.db.Query(statusQueryBuilder{
limit: strconv.Itoa(perPage + 1),

View file

@ -1,13 +1,14 @@
package storage
import (
"errors"
"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) {
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
}
@ -17,7 +18,10 @@ func (s *Storage) VerifyUser(user model.User) (model.User, error) {
return u, err
}
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
}
@ -37,12 +41,12 @@ func (s *Storage) CreateUser(user model.User) error {
if err != nil {
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)
if err != nil {
return err
}
_, err = statement.Exec(user.Name, hash)
_, err = statement.Exec(user.Name, hash, user.Email, user.SignupEmail, user.SignupMsg)
return err
}
@ -63,13 +67,34 @@ func (s *Storage) Users() ([]string, error) {
return users, nil
}
func (s *Storage) DeleteUser(username string) error {
stmt, err := s.db.Prepare(`DELETE from status WHERE author = $1;`)
func (s *Storage) InactiveUsers() ([]model.User, error) {
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 {
return err
}
_, err = stmt.Exec(username)
stmt, err = s.db.Prepare(`DELETE from users WHERE name = $1;`)
_, err = stmt.Exec(name)
return err
}
func (s *Storage) DeleteUser(username string) error {
stmt, err := s.db.Prepare(`DELETE from users WHERE name = $1;`)
if err != nil {
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 {
Username string
Password string
Confirm string
Key string
Error string
Username string
Password string
Email string
ShowEmail bool
Answer string
Confirm string
Key string
Error string
}
func (f *RegisterForm) Validate() error {
@ -33,9 +36,12 @@ func (f *RegisterForm) Validate() error {
func NewRegisterForm(r *http.Request) *RegisterForm {
return &RegisterForm{
Username: r.FormValue("name"),
Password: r.FormValue("password"),
Confirm: r.FormValue("password-confirm"),
Key: r.FormValue("key"),
Username: r.FormValue("name"),
Password: r.FormValue("password"),
Confirm: r.FormValue("password-confirm"),
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,
}
h.initTpl()
router.HandleFunc("/", h.showIndexView).Methods(http.MethodGet)
router.HandleFunc("/login", h.showLoginView).Methods(http.MethodGet)
router.HandleFunc("/check-login", h.checkLogin).Methods(http.MethodPost)
router.HandleFunc("/register", h.handleRegister)
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-update", h.updateSettings).Methods(http.MethodPost)

View file

@ -3,6 +3,17 @@
package handler
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" }}
<section>
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="/current-status">status widget</a> for your homepage</li>
</ul>
<p><a href="/feed.atom">Subscribe via Atom</a></p>
{{ else }}
<h2>Welcome!</h2>
<p>status.cafe is a place to share your current status.</p>
@ -246,26 +258,54 @@ var TplMap = map[string]string{
{{ end }}`,
"register": `{{ define "content" }}
<section>
<h1>Register</h1>
{{ if .form.Error }}
<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 class="cols">
<div>
<h1>Register</h1>
<div class="info">
<p>Registrations are manually approved to prevent spam and keep this little cafe a cool place to hang out.</p>
<p>You should receive a confirmation within a few hours at the email you have provided. Make sure to enter a valid address!</p>
</div>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required/>
<div>
{{ if .form.Error }}
<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 class="field">
<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>
</div>
</section>
{{ end }}`,
"register-success": `{{ 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 }}`,
"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="/current-status">status widget</a> for your homepage</li>
</ul>
<p><a href="/feed.atom">Subscribe via Atom</a></p>
{{ else }}
<h2>Welcome!</h2>
<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" }}
<section>
<h1>Register</h1>
{{ if .form.Error }}
<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 class="cols">
<div>
<h1>Register</h1>
<div class="info">
<p>Registrations are manually approved to prevent spam and keep this little cafe a cool place to hang out.</p>
<p>You should receive a confirmation within a few hours at the email you have provided. Make sure to enter a valid address!</p>
</div>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" required/>
<div>
{{ if .form.Error }}
<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 class="field">
<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>
</div>
</section>
{{ end }}

View file

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

View file

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

View file

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

View file

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