commit
e853a15303
15
account.go
15
account.go
|
@ -45,6 +45,7 @@ type (
|
||||||
PageTitle string
|
PageTitle string
|
||||||
Separator template.HTML
|
Separator template.HTML
|
||||||
IsAdmin bool
|
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.Flashes = flashes
|
||||||
up.Path = r.URL.Path
|
up.Path = r.URL.Path
|
||||||
up.IsAdmin = u.IsAdmin()
|
up.IsAdmin = u.IsAdmin()
|
||||||
|
up.CanInvite = app.cfg.App.UserInvites != "" &&
|
||||||
|
(up.IsAdmin || app.cfg.App.UserInvites != "admin")
|
||||||
return up
|
return up
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,6 +167,18 @@ func signupWithRegistration(app *app, signup userRegistration, w http.ResponseWr
|
||||||
return nil, err
|
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
|
// Add back unencrypted data for response
|
||||||
if signup.Email != "" {
|
if signup.Email != "" {
|
||||||
u.Email.String = signup.Email
|
u.Email.String = signup.Email
|
||||||
|
|
4
admin.go
4
admin.go
|
@ -262,6 +262,10 @@ func handleAdminUpdateConfig(app *app, u *User, w http.ResponseWriter, r *http.R
|
||||||
log.Info("Initializing local timeline...")
|
log.Info("Initializing local timeline...")
|
||||||
initLocalTimeline(app)
|
initLocalTimeline(app)
|
||||||
}
|
}
|
||||||
|
app.cfg.App.UserInvites = r.FormValue("user_invites")
|
||||||
|
if app.cfg.App.UserInvites == "none" {
|
||||||
|
app.cfg.App.UserInvites = ""
|
||||||
|
}
|
||||||
|
|
||||||
m := "?cm=Configuration+saved."
|
m := "?cm=Configuration+saved."
|
||||||
err = config.Save(app.cfg, app.cfgFile)
|
err = config.Save(app.cfg, app.cfgFile)
|
||||||
|
|
|
@ -55,6 +55,7 @@ var reservedUsernames = map[string]bool{
|
||||||
"guides": true,
|
"guides": true,
|
||||||
"help": true,
|
"help": true,
|
||||||
"index": true,
|
"index": true,
|
||||||
|
"invite": true,
|
||||||
"js": true,
|
"js": true,
|
||||||
"login": true,
|
"login": true,
|
||||||
"logout": true,
|
"logout": true,
|
||||||
|
|
|
@ -18,9 +18,14 @@ import (
|
||||||
const (
|
const (
|
||||||
// FileName is the default configuration file name
|
// FileName is the default configuration file name
|
||||||
FileName = "config.ini"
|
FileName = "config.ini"
|
||||||
|
|
||||||
|
UserNormal UserType = "user"
|
||||||
|
UserAdmin = "admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
UserType string
|
||||||
|
|
||||||
// ServerCfg holds values that affect how the HTTP server runs
|
// ServerCfg holds values that affect how the HTTP server runs
|
||||||
ServerCfg struct {
|
ServerCfg struct {
|
||||||
HiddenHost string `ini:"hidden_host"`
|
HiddenHost string `ini:"hidden_host"`
|
||||||
|
@ -72,7 +77,8 @@ type (
|
||||||
Private bool `ini:"private"`
|
Private bool `ini:"private"`
|
||||||
|
|
||||||
// Additional functions
|
// 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
|
// Config holds the complete configuration for running a writefreely instance
|
||||||
|
|
60
database.go
60
database.go
|
@ -109,6 +109,11 @@ type writestore interface {
|
||||||
|
|
||||||
GetAPFollowers(c *Collection) (*[]RemoteUser, error)
|
GetAPFollowers(c *Collection) (*[]RemoteUser, error)
|
||||||
GetAPActorKeys(collectionID int64) ([]byte, []byte)
|
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)
|
GetDynamicContent(id string) (string, *time.Time, error)
|
||||||
UpdateDynamicContent(id, content string) error
|
UpdateDynamicContent(id, content string) error
|
||||||
|
@ -2202,6 +2207,61 @@ func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) {
|
||||||
return pub, priv
|
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) {
|
func (db *datastore) GetDynamicContent(id string) (string, *time.Time, error) {
|
||||||
var c string
|
var c string
|
||||||
var u *time.Time
|
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)
|
return m.migrate(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
var migrations = []Migration{}
|
var migrations = []Migration{
|
||||||
|
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
|
||||||
|
}
|
||||||
|
|
||||||
func Migrate(db *datastore) error {
|
func Migrate(db *datastore) error {
|
||||||
var version int
|
var version int
|
||||||
|
@ -102,7 +104,7 @@ func (db *datastore) tableExists(t string) bool {
|
||||||
if db.driverName == driverSQLite {
|
if db.driverName == driverSQLite {
|
||||||
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", t).Scan(&dummy)
|
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", t).Scan(&dummy)
|
||||||
} else {
|
} else {
|
||||||
err = db.QueryRow("SHOW TABLES LIKE ?", t).Scan(&dummy)
|
err = db.QueryRow("SHOW TABLES LIKE '" + t + "'").Scan(&dummy)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
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", handler.User(viewExportOptions)).Methods("GET")
|
||||||
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
|
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
|
||||||
me.HandleFunc("/settings", handler.User(viewSettings)).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")
|
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
|
||||||
|
|
||||||
write.HandleFunc("/api/me", handler.All(viewMeAPI)).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("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET")
|
||||||
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
|
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
|
||||||
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
|
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
|
||||||
|
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
|
||||||
|
|
||||||
// Sign up validation
|
// Sign up validation
|
||||||
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
|
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("/claim", handler.All(addPost)).Methods("POST")
|
||||||
posts.HandleFunc("/disperse", handler.All(dispersePost)).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("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
|
||||||
|
|
||||||
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
|
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
|
// Handle special pages first
|
||||||
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
|
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: 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
|
// TODO: change this based on configuration for either public or private-to-this-instance
|
||||||
readPerm := UserLevelOptional
|
readPerm := UserLevelOptional
|
||||||
|
|
26
schema.sql
26
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`
|
-- Table structure for table `users`
|
||||||
--
|
--
|
||||||
|
@ -201,3 +216,14 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `username` (`username`)
|
UNIQUE KEY `username` (`username`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
|
) 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;
|
||||||
|
|
26
sqlite.sql
26
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
|
-- Table structure for table users
|
||||||
--
|
--
|
||||||
|
@ -189,3 +204,14 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||||
email TEXT DEFAULT NULL,
|
email TEXT DEFAULT NULL,
|
||||||
created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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>
|
<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>
|
<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>
|
<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>
|
</dl>
|
||||||
<input type="submit" value="Save Configuration" />
|
<input type="submit" value="Save Configuration" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -43,6 +43,7 @@
|
||||||
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
|
<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/settings">Account settings</a></li>
|
||||||
<li><a href="/me/export">Export</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 class="separator"><hr /></li>
|
||||||
<li><a href="/me/logout">Log out</a></li>
|
<li><a href="/me/logout">Log out</a></li>
|
||||||
</ul></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.Web = true
|
||||||
ur.Normalize = true
|
ur.Normalize = true
|
||||||
|
|
||||||
|
to := "/"
|
||||||
|
if ur.InviteCode != "" {
|
||||||
|
to = "/invite/" + ur.InviteCode
|
||||||
|
}
|
||||||
_, err := signupWithRegistration(app, ur, w, r)
|
_, err := signupWithRegistration(app, ur, w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err, ok := err.(impart.HTTPError); ok {
|
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 {
|
if session != nil {
|
||||||
session.AddFlash(err.Message)
|
session.AddFlash(err.Message)
|
||||||
session.Save(r, w)
|
session.Save(r, w)
|
||||||
return impart.HTTPError{http.StatusFound, "/"}
|
return impart.HTTPError{http.StatusFound, to}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return impart.HTTPError{http.StatusFound, "/"}
|
return impart.HTTPError{http.StatusFound, to}
|
||||||
}
|
}
|
||||||
|
|
||||||
// { "username": "asdf" }
|
// { "username": "asdf" }
|
||||||
|
|
7
users.go
7
users.go
|
@ -31,9 +31,10 @@ type (
|
||||||
|
|
||||||
userRegistration struct {
|
userRegistration struct {
|
||||||
userCredentials
|
userCredentials
|
||||||
Honeypot string `json:"fullname" schema:"fullname"`
|
InviteCode string `json:"invite_code" schema:"invite_code"`
|
||||||
Normalize bool `json:"normalize" schema:"normalize"`
|
Honeypot string `json:"fullname" schema:"fullname"`
|
||||||
Signup bool `json:"signup" schema:"signup"`
|
Normalize bool `json:"normalize" schema:"normalize"`
|
||||||
|
Signup bool `json:"signup" schema:"signup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthUser contains information for a newly authenticated user (either
|
// AuthUser contains information for a newly authenticated user (either
|
||||||
|
|
Loading…
Reference in New Issue