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"}} +
{{.Error}}
+ {{ else }} + {{if .Flashes}}Invite others to join {{.SiteName}} by generating and sharing invite links below.
+ + + +Link | +Uses | +Expires | +
---|---|---|
{{$.Host}}/invite/{{.ID}} | +{{.Uses}}{{if gt .MaxUses.Int64 0}} / {{.MaxUses.Int64}}{{end}} | +{{ if .Expires }}{{if .Expired}}Expired{{else}}{{.ExpiresFriendly}}{{end}}{{ else }}∞{{ end }} | +
No invites generated yet. | +