diff --git a/blog.go b/blog.go index 8546846..dce27c7 100644 --- a/blog.go +++ b/blog.go @@ -4,6 +4,7 @@ package blog import ( "fmt" "net/http" + "os" "path/filepath" "github.com/gorilla/mux" @@ -56,6 +57,10 @@ type Blog struct { // New initializes the Blog application. 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")) if err != nil { panic(err) diff --git a/cmd/blog/main.go b/cmd/blog/main.go index ad9bc0a..f0647e1 100644 --- a/cmd/blog/main.go +++ b/cmd/blog/main.go @@ -7,7 +7,9 @@ package main import ( "flag" "fmt" + "math/rand" "os" + "time" _ "github.com/jinzhu/gorm/dialects/sqlite" // SQLite DB "github.com/kirsle/blog" @@ -32,6 +34,7 @@ func init() { flag.BoolVar(&fDebug, "d", false, "Debug mode (alias)") flag.StringVar(&fAddress, "address", ":8000", "Bind address") flag.StringVar(&fAddress, "a", ":8000", "Bind address (alias)") + rand.Seed(time.Now().UnixNano()) } func main() { diff --git a/internal/controllers/events/contact-auth.go b/internal/controllers/events/contact-auth.go new file mode 100644 index 0000000..d453c73 --- /dev/null +++ b/internal/controllers/events/contact-auth.go @@ -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/?e=" +// +// 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 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, "/") + } +} diff --git a/internal/controllers/events/events.go b/internal/controllers/events/events.go index b8cda61..7407e02 100644 --- a/internal/controllers/events/events.go +++ b/internal/controllers/events/events.go @@ -29,6 +29,8 @@ func Register(r *mux.Router, loginError http.HandlerFunc) { // Public routes r.HandleFunc("/e/{fragment}", viewHandler) + r.HandleFunc("/c/logout", contactLogoutHandler) + r.HandleFunc("/c/{secret}", contactAuthHandler) } // Admin index to view all events. @@ -60,10 +62,50 @@ func viewHandler(w http.ResponseWriter, r *http.Request) { return } + // Template variables. + v := map[string]interface{}{ + "event": event, + "authedRSVP": events.RSVP{}, + } + + // Sort the guest list. sort.Sort(events.ByName(event.RSVP)) - v := map[string]interface{}{ - "event": event, + // Is the browser session authenticated as a contact? + 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) } diff --git a/internal/controllers/events/notifier.go b/internal/controllers/events/notifier.go index f213b7f..5dd6eb4 100644 --- a/internal/controllers/events/notifier.go +++ b/internal/controllers/events/notifier.go @@ -16,15 +16,26 @@ func notifyUser(ev *events.Event, rsvp events.RSVP) { ) 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? if email != "" { - mail.SendEmail(mail.Email{ + go mail.SendEmail(mail.Email{ To: email, Subject: fmt.Sprintf("Invitation to: %s", ev.Title), Data: map[string]interface{}{ - "RSVP": rsvp, - "Event": ev, - "URL": strings.Trim(s.Site.URL, "/") + "/e/" + ev.Fragment, + "RSVP": rsvp, + "Event": ev, + "URL": strings.Trim(s.Site.URL, "/") + "/e/" + ev.Fragment, + "ClaimURL": claimURL, }, Template: ".email/event-invite.gohtml", }) @@ -32,7 +43,7 @@ func notifyUser(ev *events.Event, rsvp events.RSVP) { // An SMS number? if sms != "" { - + // TODO: Twilio } rsvp.Notified = true diff --git a/models/contacts/contacts.go b/models/contacts/contacts.go index d85a9d6..00ae2d3 100644 --- a/models/contacts/contacts.go +++ b/models/contacts/contacts.go @@ -2,6 +2,7 @@ package contacts import ( "errors" + "fmt" "math/rand" "net/http" "strings" @@ -65,6 +66,7 @@ func (c *Contact) presave() { secret[i] = letters[rand.Intn(len(letters))] } 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 } +// 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. func (c Contact) Name() string { var parts []string diff --git a/root/.email/event-invite.gohtml b/root/.email/event-invite.gohtml index 845f790..d5d8008 100644 --- a/root/.email/event-invite.gohtml +++ b/root/.email/event-invite.gohtml @@ -30,7 +30,7 @@ To view the details and RSVP, visit the link below:

- {{ .Data.URL }} + {{ or .Data.ClaimURL .Data.URL }} diff --git a/root/events/invite.gohtml b/root/events/invite.gohtml index c30d755..2222cee 100644 --- a/root/events/invite.gohtml +++ b/root/events/invite.gohtml @@ -40,7 +40,7 @@ {{ else }} {{ $rsvp.Name }} {{ end }} - +
    {{ if .Contact }} diff --git a/root/events/view.gohtml b/root/events/view.gohtml index e69ba93..189253f 100644 --- a/root/events/view.gohtml +++ b/root/events/view.gohtml @@ -1,7 +1,32 @@ {{ define "title" }}{{ .Data.event.Title }}{{ end }} {{ define "content" }} +{{ $authedContact := .Data.authedContact }} +{{ $authedRSVP := .Data.authedRSVP }} + {{ with .Data.event }} + {{ if and $authedContact $authedRSVP.ID }} +
    +
    +

    + {{ $authedContact.Name }}, you have been invited to... +

    +
    +
    +
    + + +
    + + + +
    +

    + [not {{ $authedContact.Name }}?] +

    +
    +
    + {{ end }}

    {{ .Title }}

    @@ -36,9 +61,9 @@ {{ range .RSVP }}
  • + {{ else if eq .Status "going"}}border-success + {{ else if eq .Status "not going"}}border-danger + {{ else if eq .Status "maybe"}}border-warning{{ end }}"> {{ if .Contact }} {{ .Contact.Name }} {{ else }} @@ -51,7 +76,7 @@ {{ if not .Notified }} not notified {{ end }} -
      +
        {{ if .Contact }} {{ if .Contact.Email }}