From 70e823d6abe4ee217a3d0930c76b14b5fc7ea1b8 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Fri, 18 Jan 2019 00:05:50 -0500 Subject: [PATCH] Support user invites This includes: - A new `user_invites` config value that determines who can generate invite links - A new page for generating invite links, with new user navigation link - A new /invite/ path that allows anyone to sign up via unique invite link, even if registrations are closed - Tracking who (of registered users) has been invited by whom It requires an updated database with `writefreely --migrate` in order to work. This closes T556 --- account.go | 15 +++ admin.go | 4 + author/author.go | 1 + config/config.go | 8 +- database.go | 60 ++++++++++ invites.go | 150 ++++++++++++++++++++++++ migrations/migrations.go | 4 +- migrations/v1.go | 46 ++++++++ pages/signup.tmpl | 176 +++++++++++++++++++++++++++++ routes.go | 7 +- schema.sql | 26 +++++ sqlite.sql | 26 +++++ templates/user/admin.tmpl | 8 ++ templates/user/include/header.tmpl | 1 + templates/user/invite.tmpl | 92 +++++++++++++++ unregisteredusers.go | 8 +- users.go | 7 +- 17 files changed, 629 insertions(+), 10 deletions(-) create mode 100644 invites.go create mode 100644 migrations/v1.go create mode 100644 pages/signup.tmpl create mode 100644 templates/user/invite.tmpl diff --git a/account.go b/account.go index 1be758e..06151c9 100644 --- a/account.go +++ b/account.go @@ -45,6 +45,7 @@ type ( PageTitle string Separator template.HTML IsAdmin bool + CanInvite bool } ) @@ -57,6 +58,8 @@ func NewUserPage(app *app, r *http.Request, u *User, title string, flashes []str up.Flashes = flashes up.Path = r.URL.Path up.IsAdmin = u.IsAdmin() + up.CanInvite = app.cfg.App.UserInvites != "" && + (up.IsAdmin || app.cfg.App.UserInvites != "admin") return up } @@ -164,6 +167,18 @@ func signupWithRegistration(app *app, signup userRegistration, w http.ResponseWr return nil, err } + // Log invite if needed + if signup.InviteCode != "" { + cu, err := app.db.GetUserForAuth(signup.Alias) + if err != nil { + return nil, err + } + err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID) + if err != nil { + return nil, err + } + } + // Add back unencrypted data for response if signup.Email != "" { u.Email.String = signup.Email diff --git a/admin.go b/admin.go index f964bf3..297fc6c 100644 --- a/admin.go +++ b/admin.go @@ -262,6 +262,10 @@ func handleAdminUpdateConfig(app *app, u *User, w http.ResponseWriter, r *http.R log.Info("Initializing local timeline...") initLocalTimeline(app) } + app.cfg.App.UserInvites = r.FormValue("user_invites") + if app.cfg.App.UserInvites == "none" { + app.cfg.App.UserInvites = "" + } m := "?cm=Configuration+saved." err = config.Save(app.cfg, app.cfgFile) diff --git a/author/author.go b/author/author.go index d196a66..c7a5ae0 100644 --- a/author/author.go +++ b/author/author.go @@ -55,6 +55,7 @@ var reservedUsernames = map[string]bool{ "guides": true, "help": true, "index": true, + "invite": true, "js": true, "login": true, "logout": true, diff --git a/config/config.go b/config/config.go index 2acf763..64ad2df 100644 --- a/config/config.go +++ b/config/config.go @@ -18,9 +18,14 @@ import ( const ( // FileName is the default configuration file name FileName = "config.ini" + + UserNormal UserType = "user" + UserAdmin = "admin" ) type ( + UserType string + // ServerCfg holds values that affect how the HTTP server runs ServerCfg struct { HiddenHost string `ini:"hidden_host"` @@ -67,7 +72,8 @@ type ( Private bool `ini:"private"` // Additional functions - LocalTimeline bool `ini:"local_timeline"` + LocalTimeline bool `ini:"local_timeline"` + UserInvites string `ini:"user_invites"` } // Config holds the complete configuration for running a writefreely instance diff --git a/database.go b/database.go index 651b357..1fa2c88 100644 --- a/database.go +++ b/database.go @@ -109,6 +109,11 @@ type writestore interface { GetAPFollowers(c *Collection) (*[]RemoteUser, error) GetAPActorKeys(collectionID int64) ([]byte, []byte) + CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error + GetUserInvites(userID int64) (*[]Invite, error) + GetUserInvite(id string) (*Invite, error) + GetUsersInvitedCount(id string) int64 + CreateInvitedUser(inviteID string, userID int64) error GetDynamicContent(id string) (string, *time.Time, error) UpdateDynamicContent(id, content string) error @@ -2202,6 +2207,61 @@ func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) { return pub, priv } +func (db *datastore) CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error { + _, err := db.Exec("INSERT INTO userinvites (id, owner_id, max_uses, created, expires, inactive) VALUES (?, ?, ?, "+db.now()+", ?, 0)", id, userID, maxUses, expires) + return err +} + +func (db *datastore) GetUserInvites(userID int64) (*[]Invite, error) { + rows, err := db.Query("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE owner_id = ? ORDER BY created DESC", userID) + if err != nil { + log.Error("Failed selecting from userinvites: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user invites."} + } + defer rows.Close() + + is := []Invite{} + for rows.Next() { + i := Invite{} + err = rows.Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive) + is = append(is, i) + } + return &is, nil +} + +func (db *datastore) GetUserInvite(id string) (*Invite, error) { + var i Invite + err := db.QueryRow("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE id = ?", id).Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive) + switch { + case err == sql.ErrNoRows: + return nil, nil + case err != nil: + log.Error("Failed selecting invite: %v", err) + return nil, err + } + + return &i, nil +} + +func (db *datastore) GetUsersInvitedCount(id string) int64 { + var count int64 + err := db.QueryRow("SELECT COUNT(*) FROM usersinvited WHERE invite_id = ?", id).Scan(&count) + switch { + case err == sql.ErrNoRows: + return 0 + case err != nil: + log.Error("Failed selecting users invited count: %v", err) + return 0 + } + + return count +} + +func (db *datastore) CreateInvitedUser(inviteID string, userID int64) error { + _, err := db.Exec("INSERT INTO usersinvited (invite_id, user_id) VALUES (?, ?)", inviteID, userID) + return err +} + func (db *datastore) GetDynamicContent(id string) (string, *time.Time, error) { var c string var u *time.Time diff --git a/invites.go b/invites.go new file mode 100644 index 0000000..54d6619 --- /dev/null +++ b/invites.go @@ -0,0 +1,150 @@ +/* + * Copyright © 2019 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "database/sql" + "github.com/gorilla/mux" + "github.com/writeas/impart" + "github.com/writeas/nerds/store" + "github.com/writeas/web-core/log" + "github.com/writeas/writefreely/page" + "html/template" + "net/http" + "strconv" + "time" +) + +type Invite struct { + ID string + MaxUses sql.NullInt64 + Created time.Time + Expires *time.Time + Inactive bool + + uses int64 +} + +func (i Invite) Uses() int64 { + return i.uses +} + +func (i Invite) Expired() bool { + return i.Expires != nil && i.Expires.Before(time.Now()) +} + +func (i Invite) ExpiresFriendly() string { + return i.Expires.Format("January 2, 2006, 3:04 PM") +} + +func handleViewUserInvites(app *app, u *User, w http.ResponseWriter, r *http.Request) error { + // Don't show page if instance doesn't allow it + if !(app.cfg.App.UserInvites != "" && (u.IsAdmin() || app.cfg.App.UserInvites != "admin")) { + return impart.HTTPError{http.StatusNotFound, ""} + } + + f, _ := getSessionFlashes(app, w, r, nil) + + p := struct { + *UserPage + Invites *[]Invite + }{ + UserPage: NewUserPage(app, r, u, "Invite People", f), + } + + var err error + p.Invites, err = app.db.GetUserInvites(u.ID) + if err != nil { + return err + } + for i := range *p.Invites { + (*p.Invites)[i].uses = app.db.GetUsersInvitedCount((*p.Invites)[i].ID) + } + + showUserPage(w, "invite", p) + return nil +} + +func handleCreateUserInvite(app *app, u *User, w http.ResponseWriter, r *http.Request) error { + muVal := r.FormValue("uses") + expVal := r.FormValue("expires") + + var err error + var maxUses int + if muVal != "0" { + maxUses, err = strconv.Atoi(muVal) + if err != nil { + return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'max_uses'"} + } + } + + var expDate *time.Time + var expires int + if expVal != "0" { + expires, err = strconv.Atoi(expVal) + if err != nil { + return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'expires'"} + } + ed := time.Now().Add(time.Duration(expires) * time.Minute) + expDate = &ed + } + + inviteID := store.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6) + err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate) + if err != nil { + return err + } + + return impart.HTTPError{http.StatusFound, "/me/invites"} +} + +func handleViewInvite(app *app, w http.ResponseWriter, r *http.Request) error { + inviteCode := mux.Vars(r)["code"] + + i, err := app.db.GetUserInvite(inviteCode) + if err != nil { + return err + } + + p := struct { + page.StaticPage + Error string + Flashes []template.HTML + Invite string + }{ + StaticPage: pageForReq(app, r), + Invite: inviteCode, + } + + if i.Expired() { + p.Error = "This invite link has expired." + } + + if i.MaxUses.Valid && i.MaxUses.Int64 > 0 { + if c := app.db.GetUsersInvitedCount(inviteCode); c >= i.MaxUses.Int64 { + p.Error = "This invite link has expired." + } + } + + // Get error messages + session, err := app.sessionStore.Get(r, cookieName) + if err != nil { + // Ignore this + log.Error("Unable to get session in handleViewInvite; ignoring: %v", err) + } + flashes, _ := getSessionFlashes(app, w, r, session) + for _, flash := range flashes { + p.Flashes = append(p.Flashes, template.HTML(flash)) + } + + // Show landing page + return renderPage(w, "signup.tmpl", p) +} diff --git a/migrations/migrations.go b/migrations/migrations.go index 0005c4a..5b9b3bc 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -54,7 +54,9 @@ func (m *migration) Migrate(db *datastore) error { return m.migrate(db) } -var migrations = []Migration{} +var migrations = []Migration{ + New("support user invites", supportUserInvites), // -> V1 (v0.8.0) +} func Migrate(db *datastore) error { var version int diff --git a/migrations/v1.go b/migrations/v1.go new file mode 100644 index 0000000..81f7d0c --- /dev/null +++ b/migrations/v1.go @@ -0,0 +1,46 @@ +/* + * Copyright © 2019 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportUserInvites(db *datastore) error { + t, err := db.Begin() + _, err = t.Exec(`CREATE TABLE userinvites ( + id ` + db.typeChar(6) + ` NOT NULL , + owner_id ` + db.typeInt() + ` NOT NULL , + max_uses ` + db.typeSmallInt() + ` NULL , + created ` + db.typeDateTime() + ` NOT NULL , + expires ` + db.typeDateTime() + ` NULL , + inactive ` + db.typeBool() + ` NOT NULL , + PRIMARY KEY (id) + ) ` + db.engine() + `;`) + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`CREATE TABLE usersinvited ( + invite_id ` + db.typeChar(6) + ` NOT NULL , + user_id ` + db.typeInt() + ` NOT NULL , + PRIMARY KEY (invite_id, user_id) + ) ` + db.engine() + `;`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/pages/signup.tmpl b/pages/signup.tmpl new file mode 100644 index 0000000..7c8707c --- /dev/null +++ b/pages/signup.tmpl @@ -0,0 +1,176 @@ +{{define "head"}} +Sign up — {{.SiteName}} + + +{{end}} +{{define "content"}} +
+ +
+
+

Sign up

+ + {{ if .Error }} +

{{.Error}}

+ {{ else }} + {{if .Flashes}}
    + {{range .Flashes}}
  • {{.}}
  • {{end}} +
{{end}} + +
+
+ +
+ + + +
+ +
+
+
+
+ {{ end }} +
+
+ + + + +{{end}} diff --git a/routes.go b/routes.go index 988bd43..b6d3cae 100644 --- a/routes.go +++ b/routes.go @@ -79,6 +79,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET") + me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET") @@ -88,6 +89,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET") apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST") apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") + apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") // Sign up validation write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") @@ -120,9 +122,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST") posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST") - if cfg.App.OpenRegistration { - write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") - } + write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") @@ -133,6 +133,7 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto // Handle special pages first write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) + write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelNoneRequired)).Methods("GET") // TODO: show a reader-specific 404 page if the function is disabled // TODO: change this based on configuration for either public or private-to-this-instance readPerm := UserLevelOptional diff --git a/schema.sql b/schema.sql index 2e2953a..6687f5d 100644 --- a/schema.sql +++ b/schema.sql @@ -188,6 +188,21 @@ CREATE TABLE IF NOT EXISTS `userattributes` ( -- -------------------------------------------------------- +-- +-- Table structure for table `userinvites` +-- + +CREATE TABLE `userinvites` ( + `id` char(6) NOT NULL, + `owner_id` int(11) NOT NULL, + `max_uses` smallint(6) DEFAULT NULL, + `created` datetime NOT NULL, + `expires` datetime DEFAULT NULL, + `inactive` tinyint(1) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + -- -- Table structure for table `users` -- @@ -201,3 +216,14 @@ CREATE TABLE IF NOT EXISTS `users` ( PRIMARY KEY (`id`), UNIQUE KEY `username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `usersinvited` +-- + +CREATE TABLE `usersinvited` ( + `invite_id` char(6) NOT NULL, + `user_id` int(11) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=latin1; diff --git a/sqlite.sql b/sqlite.sql index 6ce2b6b..6c7160a 100644 --- a/sqlite.sql +++ b/sqlite.sql @@ -178,6 +178,21 @@ CREATE TABLE IF NOT EXISTS `userattributes` ( -- -------------------------------------------------------- +-- +-- Table structure for table `userinvites` +-- + +CREATE TABLE `userinvites` ( + `id` TEXT NOT NULL, + `owner_id` INTEGER NOT NULL, + `max_uses` INTEGER DEFAULT NULL, + `created` DATETIME NOT NULL, + `expires` DATETIME DEFAULT NULL, + `inactive` INTEGER NOT NULL +); + +-- -------------------------------------------------------- + -- -- Table structure for table users -- @@ -189,3 +204,14 @@ CREATE TABLE IF NOT EXISTS `users` ( email TEXT DEFAULT NULL, created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `usersinvited` +-- + +CREATE TABLE `usersinvited` ( + `invite_id` TEXT NOT NULL, + `user_id` INTEGER NOT NULL +); diff --git a/templates/user/admin.tmpl b/templates/user/admin.tmpl index 223b3c1..da1e850 100644 --- a/templates/user/admin.tmpl +++ b/templates/user/admin.tmpl @@ -116,6 +116,14 @@ function savePage(el) {
diff --git a/templates/user/include/header.tmpl b/templates/user/include/header.tmpl index d28fee4..76b1425 100644 --- a/templates/user/include/header.tmpl +++ b/templates/user/include/header.tmpl @@ -43,6 +43,7 @@