mirror of https://github.com/writeas/writefreely
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 T556pull/68/head
parent
47b2155f92
commit
70e823d6ab
@ -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) |
||||||
|
} |
@ -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}} |
@ -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}} |
Loading…
Reference in new issue