Anonymous contact authentication for events
This commit is contained in:
parent
a166c72cf3
commit
a3ba16c9b2
5
blog.go
5
blog.go
|
@ -4,6 +4,7 @@ package blog
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -56,6 +57,10 @@ type Blog struct {
|
||||||
|
|
||||||
// New initializes the Blog application.
|
// New initializes the Blog application.
|
||||||
func New(documentRoot, userRoot string) *Blog {
|
func New(documentRoot, userRoot string) *Blog {
|
||||||
|
// Initialize the SQLite database.
|
||||||
|
if _, err := os.Stat(filepath.Join(userRoot, ".private")); os.IsNotExist(err) {
|
||||||
|
os.MkdirAll(filepath.Join(userRoot, ".private"), 0755)
|
||||||
|
}
|
||||||
db, err := gorm.Open("sqlite3", filepath.Join(userRoot, ".private", "database.sqlite"))
|
db, err := gorm.Open("sqlite3", filepath.Join(userRoot, ".private", "database.sqlite"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|
|
@ -7,7 +7,9 @@ package main
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/jinzhu/gorm/dialects/sqlite" // SQLite DB
|
_ "github.com/jinzhu/gorm/dialects/sqlite" // SQLite DB
|
||||||
"github.com/kirsle/blog"
|
"github.com/kirsle/blog"
|
||||||
|
@ -32,6 +34,7 @@ func init() {
|
||||||
flag.BoolVar(&fDebug, "d", false, "Debug mode (alias)")
|
flag.BoolVar(&fDebug, "d", false, "Debug mode (alias)")
|
||||||
flag.StringVar(&fAddress, "address", ":8000", "Bind address")
|
flag.StringVar(&fAddress, "address", ":8000", "Bind address")
|
||||||
flag.StringVar(&fAddress, "a", ":8000", "Bind address (alias)")
|
flag.StringVar(&fAddress, "a", ":8000", "Bind address (alias)")
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
88
internal/controllers/events/contact-auth.go
Normal file
88
internal/controllers/events/contact-auth.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/kirsle/blog/internal/log"
|
||||||
|
"github.com/kirsle/blog/internal/responses"
|
||||||
|
"github.com/kirsle/blog/internal/sessions"
|
||||||
|
"github.com/kirsle/blog/models/contacts"
|
||||||
|
"github.com/kirsle/blog/models/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthedContact returns the current authenticated Contact, if any, on the session.
|
||||||
|
func AuthedContact(r *http.Request) (contacts.Contact, error) {
|
||||||
|
session := sessions.Get(r)
|
||||||
|
if contactID, ok := session.Values["contact-id"].(int); ok && contactID != 0 {
|
||||||
|
contact, err := contacts.Get(contactID)
|
||||||
|
return contact, err
|
||||||
|
}
|
||||||
|
return contacts.Contact{}, errors.New("not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// contactAuthHandler listens at "/c/<contact secret>?e=<event id>"
|
||||||
|
//
|
||||||
|
// It is used in RSVP invite emails so when the user clicks the link, it auto
|
||||||
|
// authenticates their session as the contact ID using the contact secret
|
||||||
|
// (a randomly generated string in the DB). The ?e= param indicates an event
|
||||||
|
// ID to redirect to.
|
||||||
|
func contactAuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
params := mux.Vars(r)
|
||||||
|
secret, ok := params["secret"]
|
||||||
|
if !ok {
|
||||||
|
responses.BadRequest(w, r, "Bad Request")
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := contacts.GetBySecret(secret)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("contactAuthHandler (/c/<secret>): secret not found, don't know this user")
|
||||||
|
responses.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("contactAuthHandler: Contact %d (%s) is now authenticated", c.ID, c.Name())
|
||||||
|
|
||||||
|
// Authenticate the contact in the session.
|
||||||
|
session := sessions.Get(r)
|
||||||
|
session.Values["contact-id"] = c.ID
|
||||||
|
err = session.Save(r, w)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("contactAuthHandler: save session error: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Did they give us an event ID?
|
||||||
|
eventIDStr := r.FormValue("e")
|
||||||
|
if eventIDStr != "" {
|
||||||
|
if eventID, err := strconv.Atoi(eventIDStr); err == nil {
|
||||||
|
event, err := events.Load(eventID)
|
||||||
|
if err != nil {
|
||||||
|
responses.FlashAndRedirect(w, r, "/", "Event %d not found", eventID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to the event.
|
||||||
|
responses.Redirect(w, "/e/"+event.Fragment)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect home I guess?
|
||||||
|
log.Error("contactAuthHandler: don't know where to send them (no ?e= param for event ID)")
|
||||||
|
responses.Redirect(w, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactLogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := sessions.Get(r)
|
||||||
|
delete(session.Values, "contact-id")
|
||||||
|
session.Save(r, w)
|
||||||
|
|
||||||
|
if next := r.FormValue("next"); next != "" && strings.HasPrefix(next, "/") {
|
||||||
|
responses.Redirect(w, next)
|
||||||
|
} else {
|
||||||
|
responses.Redirect(w, "/")
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,6 +29,8 @@ func Register(r *mux.Router, loginError http.HandlerFunc) {
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
r.HandleFunc("/e/{fragment}", viewHandler)
|
r.HandleFunc("/e/{fragment}", viewHandler)
|
||||||
|
r.HandleFunc("/c/logout", contactLogoutHandler)
|
||||||
|
r.HandleFunc("/c/{secret}", contactAuthHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin index to view all events.
|
// Admin index to view all events.
|
||||||
|
@ -60,10 +62,50 @@ func viewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Template variables.
|
||||||
|
v := map[string]interface{}{
|
||||||
|
"event": event,
|
||||||
|
"authedRSVP": events.RSVP{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the guest list.
|
||||||
sort.Sort(events.ByName(event.RSVP))
|
sort.Sort(events.ByName(event.RSVP))
|
||||||
|
|
||||||
v := map[string]interface{}{
|
// Is the browser session authenticated as a contact?
|
||||||
"event": event,
|
authedContact, err := AuthedContact(r)
|
||||||
|
if err == nil {
|
||||||
|
v["authedContact"] = authedContact
|
||||||
|
|
||||||
|
// Do they have an RSVP?
|
||||||
|
for _, rsvp := range event.RSVP {
|
||||||
|
if rsvp.ContactID == authedContact.ID {
|
||||||
|
v["authedRSVP"] = rsvp
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're posting, are we RSVPing?
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
action := r.PostFormValue("action")
|
||||||
|
switch action {
|
||||||
|
case "answer-rsvp":
|
||||||
|
answer := r.PostFormValue("submit")
|
||||||
|
for _, rsvp := range event.RSVP {
|
||||||
|
if rsvp.ContactID == authedContact.ID {
|
||||||
|
log.Info("Mark RSVP status %s for contact %s", answer, authedContact.Name())
|
||||||
|
rsvp.Status = answer
|
||||||
|
rsvp.Save()
|
||||||
|
responses.FlashAndReload(w, r, "You have confirmed '%s' for your RSVP.", answer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
responses.FlashAndReload(w, r, "Invalid form action.")
|
||||||
|
}
|
||||||
|
responses.FlashAndReload(w, r, "Unknown error.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
render.Template(w, r, "events/view", v)
|
render.Template(w, r, "events/view", v)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,15 +16,26 @@ func notifyUser(ev *events.Event, rsvp events.RSVP) {
|
||||||
)
|
)
|
||||||
s, _ := settings.Load()
|
s, _ := settings.Load()
|
||||||
|
|
||||||
|
// Can we get an "auto-login" link?
|
||||||
|
var claimURL string
|
||||||
|
if rsvp.Contact.Secret != "" {
|
||||||
|
claimURL = fmt.Sprintf("%s/c/%s?e=%d",
|
||||||
|
strings.Trim(s.Site.URL, "/"),
|
||||||
|
rsvp.Contact.Secret,
|
||||||
|
ev.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Do they have... an e-mail address?
|
// Do they have... an e-mail address?
|
||||||
if email != "" {
|
if email != "" {
|
||||||
mail.SendEmail(mail.Email{
|
go mail.SendEmail(mail.Email{
|
||||||
To: email,
|
To: email,
|
||||||
Subject: fmt.Sprintf("Invitation to: %s", ev.Title),
|
Subject: fmt.Sprintf("Invitation to: %s", ev.Title),
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"RSVP": rsvp,
|
"RSVP": rsvp,
|
||||||
"Event": ev,
|
"Event": ev,
|
||||||
"URL": strings.Trim(s.Site.URL, "/") + "/e/" + ev.Fragment,
|
"URL": strings.Trim(s.Site.URL, "/") + "/e/" + ev.Fragment,
|
||||||
|
"ClaimURL": claimURL,
|
||||||
},
|
},
|
||||||
Template: ".email/event-invite.gohtml",
|
Template: ".email/event-invite.gohtml",
|
||||||
})
|
})
|
||||||
|
@ -32,7 +43,7 @@ func notifyUser(ev *events.Event, rsvp events.RSVP) {
|
||||||
|
|
||||||
// An SMS number?
|
// An SMS number?
|
||||||
if sms != "" {
|
if sms != "" {
|
||||||
|
// TODO: Twilio
|
||||||
}
|
}
|
||||||
|
|
||||||
rsvp.Notified = true
|
rsvp.Notified = true
|
||||||
|
|
|
@ -2,6 +2,7 @@ package contacts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -65,6 +66,7 @@ func (c *Contact) presave() {
|
||||||
secret[i] = letters[rand.Intn(len(letters))]
|
secret[i] = letters[rand.Intn(len(letters))]
|
||||||
}
|
}
|
||||||
c.Secret = string(secret)
|
c.Secret = string(secret)
|
||||||
|
fmt.Printf("contact.presave: secret=%s", string(secret))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,6 +113,13 @@ func GetSMS(number string) (Contact, error) {
|
||||||
return contact, err
|
return contact, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBySecret queries a contact by their secret.
|
||||||
|
func GetBySecret(secret string) (Contact, error) {
|
||||||
|
contact := Contact{}
|
||||||
|
err := DB.Where("secret = ?", secret).First(&contact).Error
|
||||||
|
return contact, err
|
||||||
|
}
|
||||||
|
|
||||||
// Name returns a friendly name for the contact.
|
// Name returns a friendly name for the contact.
|
||||||
func (c Contact) Name() string {
|
func (c Contact) Name() string {
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
To view the details and RSVP, visit the link below:
|
To view the details and RSVP, visit the link below:
|
||||||
<br><br>
|
<br><br>
|
||||||
|
|
||||||
<a href="{{ .Data.URL }}" target="_blank">{{ .Data.URL }}</a>
|
<a href="{{ or .Data.ClaimURL .Data.URL }}" target="_blank">{{ or .Data.ClaimURL .Data.URL }}</a>
|
||||||
</font>
|
</font>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -40,7 +40,7 @@
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<strong>{{ $rsvp.Name }}</strong>
|
<strong>{{ $rsvp.Name }}</strong>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<button type="submit" class="btn btn-sm btn-danger">delete</button>
|
<button type="submit" class="btn btn-sm btn-danger">uninvite</button>
|
||||||
</form>
|
</form>
|
||||||
<ul class="list-inline">
|
<ul class="list-inline">
|
||||||
{{ if .Contact }}
|
{{ if .Contact }}
|
||||||
|
|
|
@ -1,7 +1,32 @@
|
||||||
{{ define "title" }}{{ .Data.event.Title }}{{ end }}
|
{{ define "title" }}{{ .Data.event.Title }}{{ end }}
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
|
||||||
|
{{ $authedContact := .Data.authedContact }}
|
||||||
|
{{ $authedRSVP := .Data.authedRSVP }}
|
||||||
|
|
||||||
{{ with .Data.event }}
|
{{ with .Data.event }}
|
||||||
|
{{ if and $authedContact $authedRSVP.ID }}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-8">
|
||||||
|
<p>
|
||||||
|
<strong>{{ $authedContact.Name }}</strong>, you have been invited to...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-4 text-right">
|
||||||
|
<form name="rsvpAnswerForm" action="/e/{{ .Fragment }}" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ $.CSRF }}">
|
||||||
|
<input type="hidden" name="action" value="answer-rsvp">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="submit" name="submit" value="going" class="btn{{ if eq $authedRSVP.Status "going" }} btn-success{{ end }}">Going</button>
|
||||||
|
<button type="submit" name="submit" value="maybe" class="btn{{ if eq $authedRSVP.Status "maybe" }} btn-warning{{ end }}">Maybe</button>
|
||||||
|
<button type="submit" name="submit" value="not going" class="btn{{ if eq $authedRSVP.Status "not going" }} btn-danger{{ end }}">Not Going</button>
|
||||||
|
</div>
|
||||||
|
<p class="small">
|
||||||
|
[<a href="/c/logout?next={{ $.Request.URL.Path }}">not {{ $authedContact.Name }}?</a>]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
<h1>{{ .Title }}</h1>
|
<h1>{{ .Title }}</h1>
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
|
@ -36,9 +61,9 @@
|
||||||
{{ range .RSVP }}
|
{{ range .RSVP }}
|
||||||
<li class="list-group-item
|
<li class="list-group-item
|
||||||
{{ if eq .Status "invited" }}bg-light
|
{{ if eq .Status "invited" }}bg-light
|
||||||
{{ else if eq .Status "going"}}bg-success text-light
|
{{ else if eq .Status "going"}}border-success
|
||||||
{{ else if eq .Status "not going"}}bg-danger text-light
|
{{ else if eq .Status "not going"}}border-danger
|
||||||
{{ else if eq .Status "maybe"}}bg-warning text-light{{ end }}">
|
{{ else if eq .Status "maybe"}}border-warning{{ end }}">
|
||||||
{{ if .Contact }}
|
{{ if .Contact }}
|
||||||
<strong>{{ .Contact.Name }}</strong>
|
<strong>{{ .Contact.Name }}</strong>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
@ -51,7 +76,7 @@
|
||||||
{{ if not .Notified }}
|
{{ if not .Notified }}
|
||||||
<span class="badge badge-warning">not notified</span>
|
<span class="badge badge-warning">not notified</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<ul class="list-inline">
|
<ul class="list-inline small">
|
||||||
{{ if .Contact }}
|
{{ if .Contact }}
|
||||||
{{ if .Contact.Email }}
|
{{ if .Contact.Email }}
|
||||||
<li class="list-inline-item text-muted">
|
<li class="list-inline-item text-muted">
|
||||||
|
|
Loading…
Reference in New Issue
Block a user