From 517a2ee86b810c10e19d09f2b766f85d8c5ce8d5 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 23 Dec 2018 16:18:04 -0800 Subject: [PATCH] Questions system WIP --- blog.go | 4 + internal/controllers/questions/questions.go | 137 ++++++++++++++++++ .../controllers/questions/questions_doc.go | 16 ++ models/questions/enums.go | 11 ++ models/questions/questions.go | 126 ++++++++++++++++ root/.email/generic.gohtml | 39 +++++ root/questions.gohtml | 67 +++++++++ 7 files changed, 400 insertions(+) create mode 100644 internal/controllers/questions/questions.go create mode 100644 internal/controllers/questions/questions_doc.go create mode 100644 models/questions/enums.go create mode 100644 models/questions/questions.go create mode 100644 root/.email/generic.gohtml create mode 100644 root/questions.gohtml diff --git a/blog.go b/blog.go index 1334dcb..83b7737 100644 --- a/blog.go +++ b/blog.go @@ -14,6 +14,7 @@ import ( commentctl "github.com/kirsle/blog/internal/controllers/comments" "github.com/kirsle/blog/internal/controllers/contact" postctl "github.com/kirsle/blog/internal/controllers/posts" + questionsctl "github.com/kirsle/blog/internal/controllers/questions" "github.com/kirsle/blog/internal/controllers/setup" "github.com/kirsle/blog/internal/log" "github.com/kirsle/blog/internal/markdown" @@ -28,6 +29,7 @@ import ( "github.com/kirsle/blog/jsondb/caches/redis" "github.com/kirsle/blog/models/comments" "github.com/kirsle/blog/models/posts" + "github.com/kirsle/blog/models/questions" "github.com/kirsle/blog/models/settings" "github.com/kirsle/blog/models/users" "github.com/shurcooL/github_flavored_markdown/gfmstyle" @@ -104,6 +106,7 @@ func (b *Blog) Configure() { posts.DB = b.jsonDB users.DB = b.jsonDB comments.DB = b.jsonDB + questions.UseDB(b.db) // Redis cache? if config.Redis.Enabled { @@ -136,6 +139,7 @@ func (b *Blog) SetupHTTP() { contact.Register(r) postctl.Register(r, b.MustLogin) commentctl.Register(r) + questionsctl.Register(r) // GitHub Flavored Markdown CSS. r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) diff --git a/internal/controllers/questions/questions.go b/internal/controllers/questions/questions.go new file mode 100644 index 0000000..0f6fdfd --- /dev/null +++ b/internal/controllers/questions/questions.go @@ -0,0 +1,137 @@ +package questions + +import ( + "fmt" + "html/template" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/internal/log" + "github.com/kirsle/blog/internal/mail" + "github.com/kirsle/blog/internal/markdown" + "github.com/kirsle/blog/internal/render" + "github.com/kirsle/blog/internal/responses" + "github.com/kirsle/blog/internal/sessions" + "github.com/kirsle/blog/models/comments" + "github.com/kirsle/blog/models/questions" + "github.com/kirsle/blog/models/settings" + "github.com/kirsle/blog/models/users" +) + +var badRequest func(http.ResponseWriter, *http.Request, string) + +// Register the comment routes to the app. +func Register(r *mux.Router) { + badRequest = responses.BadRequest + + r.HandleFunc("/ask", questionsHandler) +} + +// CommentMeta is the template variables for comment threads. +type CommentMeta struct { + NewComment comments.Comment + ID string + OriginURL string // URL where original comment thread appeared + Subject string // email subject + Thread *comments.Thread + Authors map[int]*users.User + CSRF string +} + +func questionsHandler(w http.ResponseWriter, r *http.Request) { + submit := r.FormValue("submit") + + // Share their name and email with the commenting system. + session := sessions.Get(r) + name, _ := session.Values["c.name"].(string) + email, _ := session.Values["c.email"].(string) + + Q := questions.New() + Q.Name = name + Q.Email = email + + cfg, err := settings.Load() + if err != nil { + responses.Error(w, r, "Error loading site configuration!") + return + } + + v := map[string]interface{}{} + + // Previewing, deleting, or posting? + if r.Method == http.MethodPost { + Q.ParseForm(r) + log.Info("Q: %+v", Q) + + switch submit { + case "ask": + if err := Q.Validate(); err != nil { + log.Debug("Validation error on question form: %s", err.Error()) + v["Error"] = err + } else { + // Cache their name and email in their session. + session.Values["c.name"] = Q.Name + session.Values["c.email"] = Q.Email + session.Save(r, w) + + // Append their comment. + err := Q.Save() + if err != nil { + log.Error("Error saving new question: %s", err.Error()) + responses.FlashAndRedirect(w, r, "/ask", "Error saving question: %s", err) + return + } + + // Email the site admin. + subject := fmt.Sprintf("Ask Me Anything (%s) from %s", cfg.Site.Title, Q.Name) + log.Info("Emailing site admin about this question") + go mail.SendEmail(mail.Email{ + To: cfg.Site.AdminEmail, + Admin: true, + ReplyTo: Q.Email, + Subject: subject, + Template: ".email/generic.gohtml", + Data: map[string]interface{}{ + "Subject": subject, + "Message": template.HTML( + markdown.RenderMarkdown(Q.Question) + "\n\n" + + "Answer this at " + strings.Trim(cfg.Site.URL, "/") + "/ask", + ), + }, + }) + + // Log it to disk, too. + fh, err := os.OpenFile(filepath.Join(*render.UserRoot, ".questions.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + responses.Flash(w, r, "Error logging the message to disk: %s", err) + } else { + fh.WriteString(fmt.Sprintf( + "Date: %s\nName: %s\nEmail: %s\n\n%s\n\n--------------------\n\n", + time.Now().Format(time.UnixDate), + Q.Name, + Q.Email, + Q.Question, + )) + fh.Close() + } + + log.Info("Recorded question from %s: %s", Q.Name, Q.Question) + responses.FlashAndRedirect(w, r, "/ask", "Your question has been recorded!") + return + } + case "answer": + case "delete": + default: + responses.FlashAndRedirect(w, r, "/ask", "Unknown submit action.") + return + } + } + + v["Q"] = Q + + render.Template(w, r, "questions.gohtml", v) +} diff --git a/internal/controllers/questions/questions_doc.go b/internal/controllers/questions/questions_doc.go new file mode 100644 index 0000000..7f792dc --- /dev/null +++ b/internal/controllers/questions/questions_doc.go @@ -0,0 +1,16 @@ +/* +Package questions implements the "Ask Me Anything" feature. + +Routes + + /ask Ask Me Anything + +Related Models + + questions + +Description + +Pending description. +*/ +package questions diff --git a/models/questions/enums.go b/models/questions/enums.go new file mode 100644 index 0000000..74889fa --- /dev/null +++ b/models/questions/enums.go @@ -0,0 +1,11 @@ +package questions + +// Status of a Question. +type Status string + +// Status options. +const ( + Pending = "pending" + Answered = "answered" + Deleted = "deleted" +) diff --git a/models/questions/questions.go b/models/questions/questions.go new file mode 100644 index 0000000..e263998 --- /dev/null +++ b/models/questions/questions.go @@ -0,0 +1,126 @@ +package questions + +import ( + "errors" + "net/http" + "net/mail" + "strconv" + "time" + + "github.com/jinzhu/gorm" + "github.com/kirsle/golog" +) + +// DB is a reference to the parent app's gorm DB. +var DB *gorm.DB + +// UseDB registers the DB from the root app. +func UseDB(db *gorm.DB) { + DB = db + DB.AutoMigrate(&Question{}) +} + +var log *golog.Logger + +func init() { + log = golog.GetLogger("blog") +} + +// Question is a question asked of the blog owner. +type Question struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Question string `json:"question"` + Status Status `json:"status"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// New creates a blank Question with sensible defaults. +func New() *Question { + return &Question{ + Status: Pending, + } +} + +// All returns all the Questions. +func All() ([]*Question, error) { + result := []*Question{} + err := DB.Order("start_time desc").Find(&result).Error + return result, err +} + +// ParseForm populates the Question from form values. +func (ev *Question) ParseForm(r *http.Request) { + id, _ := strconv.Atoi(r.FormValue("id")) + + ev.ID = id + ev.Name = r.FormValue("name") + ev.Email = r.FormValue("email") + ev.Question = r.FormValue("question") +} + +// parseDateTime parses separate date + time fields into a single time.Time. +func parseDateTime(r *http.Request, dateField, timeField string) (time.Time, error) { + dateValue := r.FormValue(dateField) + timeValue := r.FormValue(timeField) + + if dateValue != "" && timeValue != "" { + datetime, err := time.Parse("2006-01-02 15:04", dateValue+" "+timeValue) + return datetime, err + } else if dateValue != "" { + datetime, err := time.Parse("2006-01-02", dateValue) + return datetime, err + } else { + return time.Time{}, errors.New("no date/times given") + } +} + +// Validate makes sure the required fields are all present. +func (ev *Question) Validate() error { + if ev.Question == "" { + return errors.New("question is required") + } + if ev.Email != "" { + if _, err := mail.ParseAddress(ev.Email); err != nil { + return err + } + } + return nil +} + +// Load an Question by its ID. +func Load(id int) (*Question, error) { + ev := &Question{} + err := DB.First(ev, id).Error + return ev, err +} + +// Save the Question. +func (ev *Question) Save() error { + if ev.Name == "" { + ev.Name = "Anonymous" + } + + // Dates & times. + if ev.Created.IsZero() { + ev.Created = time.Now().UTC() + } + if ev.Updated.IsZero() { + ev.Updated = ev.Created + } + + // Write the Question. + return DB.Save(&ev).Error +} + +// Delete an Question. +func (ev *Question) Delete() error { + if ev.ID == 0 { + return errors.New("Question has no ID") + } + + // Delete the DB files. + return DB.Delete(ev).Error +} diff --git a/root/.email/generic.gohtml b/root/.email/generic.gohtml new file mode 100644 index 0000000..2d706db --- /dev/null +++ b/root/.email/generic.gohtml @@ -0,0 +1,39 @@ + + + + + + + + {{ .Subject }} + + + +
+ + + + + + + + + + +
+ + {{ .Subject }} + +
+ + {{ .Data.Message }} + +
+ + This e-mail was automatically generated; do not reply to it. + +
+
+ + + diff --git a/root/questions.gohtml b/root/questions.gohtml new file mode 100644 index 0000000..0dd6620 --- /dev/null +++ b/root/questions.gohtml @@ -0,0 +1,67 @@ +{{ define "title" }}Ask Me Anything{{ end }} +{{ define "content" }} +

Ask Me Anything

+ +{{ if .Data.Error }} +
+ Error: {{ .Data.Error }} +
+{{ end }} + +
+
+
+ + +
+
+ +
+
+ +
+
+
+
+ +
+
+
+ + Optional. You will receive a one-time e-mail when I answer your question and no spam. + +
+
+
+ + +
+ +
+
+ +
+
+
+
+ + {{ if .LoggedIn }} +
+
+ Pending Questions +
+
+ xxx +
+
+ {{ end }} +
+ +{{ end }}