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

@ -87,3 +87,7 @@ dd {
.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

@ -10,6 +10,10 @@ type (
CertFile string CertFile string
KeyFile string KeyFile string
AssetsDir string AssetsDir string
EmailUsername string
EmailPassword string
EmailHost string
EmailHostAddr string
} }
) )

View file

@ -13,9 +13,11 @@ type User struct {
Hash []byte Hash []byte
Homepage string Homepage string
About string About string
Style string
Picture string Picture string
Email string Email string
SignupEmail string
SignupMsg string
Active bool
CreatedAt time.Time CreatedAt time.Time
} }

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

@ -9,6 +9,9 @@ import (
type RegisterForm struct { type RegisterForm struct {
Username string Username string
Password string Password string
Email string
ShowEmail bool
Answer string
Confirm string Confirm string
Key string Key string
Error string Error string
@ -36,6 +39,9 @@ func NewRegisterForm(r *http.Request) *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"),
Email: r.FormValue("email"),
ShowEmail: r.FormValue("show-email") == "1",
Answer: r.FormValue("answer"),
Key: r.FormValue("key"), 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,7 +258,15 @@ var TplMap = map[string]string{
{{ end }}`, {{ end }}`,
"register": `{{ define "content" }} "register": `{{ define "content" }}
<section> <section>
<div class="cols">
<div>
<h1>Register</h1> <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>
{{ if .form.Error }} {{ if .form.Error }}
<p>{{ .form.Error }}</p> <p>{{ .form.Error }}</p>
{{ end }} {{ end }}
@ -255,6 +275,14 @@ var TplMap = map[string]string{
<label for="name">Username</label> <label for="name">Username</label>
<input type="text" id="name" name="name" autocomplete="off" required autofocus/> <input type="text" id="name" name="name" autocomplete="off" required autofocus/>
</div> </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"> <div class="field">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required/> <input type="password" id="password" name="password" required/>
@ -263,9 +291,21 @@ var TplMap = map[string]string{
<label for="password-confirm">Confirm password</label> <label for="password-confirm">Confirm password</label>
<input type="password" id="password-confirm" name="password-confirm" required/> <input type="password" id="password-confirm" name="password-confirm" required/>
</div> </div>
<br> <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"> <input type="submit" value="Submit">
</form> </form>
</div>
</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> </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,6 +1,14 @@
{{ define "content" }} {{ define "content" }}
<section> <section>
<div class="cols">
<div>
<h1>Register</h1> <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>
{{ if .form.Error }} {{ if .form.Error }}
<p>{{ .form.Error }}</p> <p>{{ .form.Error }}</p>
{{ end }} {{ end }}
@ -9,6 +17,14 @@
<label for="name">Username</label> <label for="name">Username</label>
<input type="text" id="name" name="name" autocomplete="off" required autofocus/> <input type="text" id="name" name="name" autocomplete="off" required autofocus/>
</div> </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"> <div class="field">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required/> <input type="password" id="password" name="password" required/>
@ -17,8 +33,13 @@
<label for="password-confirm">Confirm password</label> <label for="password-confirm">Confirm password</label>
<input type="password" id="password-confirm" name="password-confirm" required/> <input type="password" id="password-confirm" name="password-confirm" required/>
</div> </div>
<br> <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"> <input type="submit" value="Submit">
</form> </form>
</div>
</div>
</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

@ -95,6 +95,11 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
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
} }