@@ -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 | |||
@@ -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) | |||
@@ -55,6 +55,7 @@ var reservedUsernames = map[string]bool{ | |||
"guides": true, | |||
"help": true, | |||
"index": true, | |||
"invite": true, | |||
"js": true, | |||
"login": true, | |||
"logout": true, | |||
@@ -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"` | |||
@@ -72,7 +77,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 | |||
@@ -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 | |||
@@ -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) | |||
} |
@@ -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 | |||
@@ -102,7 +104,7 @@ func (db *datastore) tableExists(t string) bool { | |||
if db.driverName == driverSQLite { | |||
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", t).Scan(&dummy) | |||
} else { | |||
err = db.QueryRow("SHOW TABLES LIKE ?", t).Scan(&dummy) | |||
err = db.QueryRow("SHOW TABLES LIKE '" + t + "'").Scan(&dummy) | |||
} | |||
switch { | |||
case err == sql.ErrNoRows: | |||
@@ -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 | |||
} |
@@ -0,0 +1,176 @@ | |||
{{define "head"}} | |||
<title>Sign up — {{.SiteName}}</title> | |||
<style type="text/css"> | |||
h2 { | |||
font-weight: normal; | |||
} | |||
#pricing.content-container div.form-container #payment-form { | |||
display: block !important; | |||
} | |||
#pricing #signup-form table { | |||
max-width: inherit !important; | |||
width: 100%; | |||
} | |||
#pricing #payment-form table { | |||
margin-top: 0 !important; | |||
max-width: inherit !important; | |||
width: 100%; | |||
} | |||
tr.subscription { | |||
border-spacing: 0; | |||
} | |||
#pricing.content-container tr.subscription button { | |||
margin-top: 0 !important; | |||
margin-bottom: 0 !important; | |||
width: 100%; | |||
} | |||
#pricing tr.subscription td { | |||
padding: 0 0.5em; | |||
} | |||
#pricing table.billing > tbody > tr > td:first-child { | |||
vertical-align: middle !important; | |||
} | |||
.billing-section { | |||
display: none; | |||
} | |||
.billing-section.bill-me { | |||
display: table-row; | |||
} | |||
#btn-create { | |||
color: white !important; | |||
} | |||
#total-price { | |||
padding-left: 0.5em; | |||
} | |||
#alias-site.demo { | |||
color: #999; | |||
} | |||
#alias-site { | |||
text-align: left; | |||
margin: 0.5em 0; | |||
} | |||
form dd { | |||
margin: 0; | |||
} | |||
</style> | |||
{{end}} | |||
{{define "content"}} | |||
<div id="pricing" class="content-container wide-form"> | |||
<div class="row"> | |||
<div style="margin: 0 auto; max-width: 25em;"> | |||
<h1>Sign up</h1> | |||
{{ if .Error }} | |||
<p style="font-style: italic">{{.Error}}</p> | |||
{{ else }} | |||
{{if .Flashes}}<ul class="errors"> | |||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} | |||
</ul>{{end}} | |||
<div id="billing"> | |||
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()"> | |||
<input type="hidden" name="invite_code" value="{{.Invite}}" /> | |||
<dl class="billing"> | |||
<label> | |||
<dt>Username</dt> | |||
<dd> | |||
<input type="text" id="alias" name="alias" style="width: 100%; box-sizing: border-box;" tabindex="1" autofocus /> | |||
{{if .Federation}}<p id="alias-site" class="demo">@<strong>your-username</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>your-username</strong></p>{{end}} | |||
</dd> | |||
</label> | |||
<label> | |||
<dt>Password</dt> | |||
<dd><input type="password" id="password" name="pass" autocomplete="new-password" placeholder="" tabindex="2" style="width: 100%; box-sizing: border-box;" /></dd> | |||
</label> | |||
<label> | |||
<dt>Email (optional)</dt> | |||
<dd><input type="email" name="email" id="email" style="letter-spacing: 1px; width: 100%; box-sizing: border-box;" placeholder="me@example.com" tabindex="3" /></dd> | |||
</label> | |||
<dt> | |||
<button id="btn-create" type="submit" style="margin-top: 0">Create blog</button> | |||
</dt> | |||
</dl> | |||
</form> | |||
</div> | |||
{{ end }} | |||
</div> | |||
</div> | |||
<script type="text/javascript" src="/js/h.js"></script> | |||
<script type="text/javascript"> | |||
function signup() { | |||
var $pass = document.getElementById('password'); | |||
// Validate input | |||
if (!aliasOK) { | |||
var $a = $alias; | |||
$a.el.className = 'error'; | |||
$a.el.focus(); | |||
$a.el.scrollIntoView(); | |||
return false; | |||
} | |||
if ($pass.value == "") { | |||
var $a = $pass; | |||
$a.className = 'error'; | |||
$a.focus(); | |||
$a.scrollIntoView(); | |||
return false; | |||
} | |||
var $btn = document.getElementById('btn-create'); | |||
$btn.disabled = true; | |||
$btn.value = 'Creating...'; | |||
return true; | |||
} | |||
var $alias = H.getEl('alias'); | |||
var $aliasSite = document.getElementById('alias-site'); | |||
var aliasOK = true; | |||
var typingTimer; | |||
var doneTypingInterval = 750; | |||
var doneTyping = function() { | |||
// Check on username | |||
var alias = $alias.el.value; | |||
if (alias != "") { | |||
var params = { | |||
username: alias | |||
}; | |||
var http = new XMLHttpRequest(); | |||
http.open("POST", '/api/alias', true); | |||
// Send the proper header information along with the request | |||
http.setRequestHeader("Content-type", "application/json"); | |||
http.onreadystatechange = function() { | |||
if (http.readyState == 4) { | |||
data = JSON.parse(http.responseText); | |||
if (http.status == 200) { | |||
aliasOK = true; | |||
$alias.removeClass('error'); | |||
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)demo(?!\S)/g, ''); | |||
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, ''); | |||
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}'; | |||
} else { | |||
aliasOK = false; | |||
$alias.setClass('error'); | |||
$aliasSite.className = 'error'; | |||
$aliasSite.textContent = data.error_msg; | |||
} | |||
} | |||
} | |||
http.send(JSON.stringify(params)); | |||
} else { | |||
$aliasSite.className += ' demo'; | |||
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>your-username</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>your-username</strong>/{{ end }}'; | |||
} | |||
}; | |||
$alias.on('keyup input', function() { | |||
clearTimeout(typingTimer); | |||
typingTimer = setTimeout(doneTyping, doneTypingInterval); | |||
}); | |||
</script> | |||
{{end}} |
@@ -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 | |||
@@ -189,6 +189,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; |
@@ -179,6 +179,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 | |||
); |
@@ -116,6 +116,14 @@ function savePage(el) { | |||
<dd><input type="checkbox" name="private" id="private" {{if .Config.Private}}checked="checked"{{end}} /></dd> | |||
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="local_timeline">Local Timeline</label></dt> | |||
<dd{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="local_timeline" id="local_timeline" {{if .Config.LocalTimeline}}checked="checked"{{end}} /></dd> | |||
<dt{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites">Allow sending invitations by</label></dt> | |||
<dd{{if .Config.SingleUser}} class="invisible"{{end}}> | |||
<select name="user_invites" id="user_invites"> | |||
<option value="none" {{if eq .Config.UserInvites ""}}selected="selected"{{end}}>No one</option> | |||
<option value="user" {{if eq .Config.UserInvites "user"}}selected="selected"{{end}}>Users</option> | |||
<option value="admin" {{if eq .Config.UserInvites "admin"}}selected="selected"{{end}}>Admins</option> | |||
</select> | |||
</dd> | |||
</dl> | |||
<input type="submit" value="Save Configuration" /> | |||
</div> | |||
@@ -43,6 +43,7 @@ | |||
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul> | |||
<li><a href="/me/settings">Account settings</a></li> | |||
<li><a href="/me/export">Export</a></li> | |||
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}} | |||
<li class="separator"><hr /></li> | |||
<li><a href="/me/logout">Log out</a></li> | |||
</ul></li> | |||
@@ -0,0 +1,92 @@ | |||
{{define "invite"}} | |||
{{template "header" .}} | |||
<style> | |||
.half { | |||
margin-right: 0.5em; | |||
} | |||
.half + .half { | |||
margin-left: 0.5em; | |||
margin-right: 0; | |||
} | |||
label { | |||
font-weight: bold; | |||
} | |||
select { | |||
font-size: 1em; | |||
width: 100%; | |||
padding: 0.5rem; | |||
display: block; | |||
border-radius: 0.25rem; | |||
margin: 0.5rem 0; | |||
} | |||
input, table.classy { | |||
width: 100%; | |||
} | |||
table.classy.export a { | |||
text-transform: initial; | |||
} | |||
table td { | |||
font-size: 0.86em; | |||
} | |||
</style> | |||
<div class="snug content-container"> | |||
<h1>Invite people</h1> | |||
<p>Invite others to join <em>{{.SiteName}}</em> by generating and sharing invite links below.</p> | |||
<form style="margin: 2em 0" action="/api/me/invites" method="post"> | |||
<div class="row"> | |||
<div class="half"> | |||
<label for="uses">Maximum number of uses:</label> | |||
<select id="uses" name="uses"> | |||
<option value="0">No limit</option> | |||
<option value="1">1 use</option> | |||
<option value="5">5 uses</option> | |||
<option value="10">10 uses</option> | |||
<option value="25">25 uses</option> | |||
<option value="50">50 uses</option> | |||
<option value="100">100 uses</option> | |||
</select> | |||
</div> | |||
<div class="half"> | |||
<label for="expires">Expire after:</label> | |||
<select id="expires" name="expires"> | |||
<option value="0">Never</option> | |||
<option value="30">30 minutes</option> | |||
<option value="60">1 hour</option> | |||
<option value="360">6 hours</option> | |||
<option value="720">12 hours</option> | |||
<option value="1440">1 day</option> | |||
<option value="4320">3 days</option> | |||
<option value="10080">1 week</option> | |||
</select> | |||
</div> | |||
</div> | |||
<div class="row"> | |||
<input type="submit" value="Generate" /> | |||
</div> | |||
</form> | |||
<table class="classy export"> | |||
<tr> | |||
<th>Link</th> | |||
<th>Uses</th> | |||
<th>Expires</th> | |||
</tr> | |||
{{range .Invites}} | |||
<tr> | |||
<td><a href="{{$.Host}}/invite/{{.ID}}">{{$.Host}}/invite/{{.ID}}</a></td> | |||
<td>{{.Uses}}{{if gt .MaxUses.Int64 0}} / {{.MaxUses.Int64}}{{end}}</td> | |||
<td>{{ if .Expires }}{{if .Expired}}Expired{{else}}{{.ExpiresFriendly}}{{end}}{{ else }}∞{{ end }}</td> | |||
</tr> | |||
{{else}} | |||
<tr> | |||
<td colspan="3">No invites generated yet.</td> | |||
</tr> | |||
{{end}} | |||
</table> | |||
</div> | |||
{{template "footer" .}} | |||
{{end}} |
@@ -46,6 +46,10 @@ func handleWebSignup(app *app, w http.ResponseWriter, r *http.Request) error { | |||
ur.Web = true | |||
ur.Normalize = true | |||
to := "/" | |||
if ur.InviteCode != "" { | |||
to = "/invite/" + ur.InviteCode | |||
} | |||
_, err := signupWithRegistration(app, ur, w, r) | |||
if err != nil { | |||
if err, ok := err.(impart.HTTPError); ok { | |||
@@ -53,12 +57,12 @@ func handleWebSignup(app *app, w http.ResponseWriter, r *http.Request) error { | |||
if session != nil { | |||
session.AddFlash(err.Message) | |||
session.Save(r, w) | |||
return impart.HTTPError{http.StatusFound, "/"} | |||
return impart.HTTPError{http.StatusFound, to} | |||
} | |||
} | |||
return err | |||
} | |||
return impart.HTTPError{http.StatusFound, "/"} | |||
return impart.HTTPError{http.StatusFound, to} | |||
} | |||
// { "username": "asdf" } | |||
@@ -31,9 +31,10 @@ type ( | |||
userRegistration struct { | |||
userCredentials | |||
Honeypot string `json:"fullname" schema:"fullname"` | |||
Normalize bool `json:"normalize" schema:"normalize"` | |||
Signup bool `json:"signup" schema:"signup"` | |||
InviteCode string `json:"invite_code" schema:"invite_code"` | |||
Honeypot string `json:"fullname" schema:"fullname"` | |||
Normalize bool `json:"normalize" schema:"normalize"` | |||
Signup bool `json:"signup" schema:"signup"` | |||
} | |||
// AuthUser contains information for a newly authenticated user (either | |||