From 1821ef60d4e6de13f026ddb6abda6bbe2c88dbeb Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Mon, 24 Dec 2018 11:47:25 -0800 Subject: [PATCH] Feature: Ask Me Anything Implements a Tumblr-style "Ask Me Anything" feature to the blog, with a bit of automation and niceness: * The route is at `/ask` * User can leave their name (Anonymous), email address (if they want to be notified when you answer) and ask their question. * The blog owner is notified about the question via email. * The owner sees recent pending questions on the `/ask` page. * Answering a question creates a blog entry and (if the asker left their email) notifies the asker to check the blog at the permalink-to-be for the new post. Along with this feature, some changes to the blog application in general: * Added support for SQL databases in addition to the JsonDB system previously in use for blogs, users, comments, etc. * Default uses a SQLite DB at $root/.private/database.sqlite * The "Ask Me Anything" feature uses SQLite models instead of JSON. * Restructure the code layout: * Rename the /internal/ package path to /src/ * Begin to consolidate models into /src/models --- blog.go | 22 +- root/questions.gohtml | 136 ++++++---- src/controllers/questions/questions.go | 241 ++++++++++++------ src/models/models.go | 21 ++ {models/questions => src/models}/questions.go | 47 ++-- .../enums.go => src/models/questions_types.go | 2 +- 6 files changed, 309 insertions(+), 160 deletions(-) create mode 100644 src/models/models.go rename {models/questions => src/models}/questions.go (76%) rename models/questions/enums.go => src/models/questions_types.go (88%) diff --git a/blog.go b/blog.go index c79a3e9..0d4bfd2 100644 --- a/blog.go +++ b/blog.go @@ -9,6 +9,14 @@ import ( "github.com/gorilla/mux" "github.com/jinzhu/gorm" + "github.com/kirsle/blog/jsondb" + "github.com/kirsle/blog/jsondb/caches" + "github.com/kirsle/blog/jsondb/caches/null" + "github.com/kirsle/blog/jsondb/caches/redis" + "github.com/kirsle/blog/models/comments" + "github.com/kirsle/blog/models/posts" + "github.com/kirsle/blog/models/settings" + "github.com/kirsle/blog/models/users" "github.com/kirsle/blog/src/controllers/admin" "github.com/kirsle/blog/src/controllers/authctl" commentctl "github.com/kirsle/blog/src/controllers/comments" @@ -20,18 +28,10 @@ import ( "github.com/kirsle/blog/src/markdown" "github.com/kirsle/blog/src/middleware" "github.com/kirsle/blog/src/middleware/auth" + "github.com/kirsle/blog/src/models" "github.com/kirsle/blog/src/render" "github.com/kirsle/blog/src/responses" "github.com/kirsle/blog/src/sessions" - "github.com/kirsle/blog/jsondb" - "github.com/kirsle/blog/jsondb/caches" - "github.com/kirsle/blog/jsondb/caches/null" - "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" "github.com/urfave/negroni" ) @@ -106,7 +106,7 @@ func (b *Blog) Configure() { posts.DB = b.jsonDB users.DB = b.jsonDB comments.DB = b.jsonDB - questions.UseDB(b.db) + models.UseDB(b.db) // Redis cache? if config.Redis.Enabled { @@ -139,7 +139,7 @@ func (b *Blog) SetupHTTP() { contact.Register(r) postctl.Register(r, b.MustLogin) commentctl.Register(r) - questionsctl.Register(r) + questionsctl.Register(r, b.MustLogin) // GitHub Flavored Markdown CSS. r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) diff --git a/root/questions.gohtml b/root/questions.gohtml index 0dd6620..c79c9b8 100644 --- a/root/questions.gohtml +++ b/root/questions.gohtml @@ -8,60 +8,100 @@ {{ end }} -
-
-
- +
+
+ + -
-
- -
-
- -
+
+
+
-
-
- -
-
-
- - Optional. You will receive a one-time e-mail when I answer your question and no spam. - -
-
-
- - -
- -
-
- -
+
+
+
+
+ +
+
+
+ + Optional. You will receive a one-time e-mail when I answer your question and no spam. + +
+
+
+ + +
+ +
+
+ +
+
+ +
+
- {{ if .LoggedIn }} -
-
- Pending Questions -
-
- xxx -
+{{ if .LoggedIn }} +
+
+ Pending Questions
- {{ end }} - +
+ {{ if not .Data.Pending }} + There are no pending questions. + {{ end }} + + {{ range .Data.Pending }} +

+ {{ .Name }} {{ if .Email }}(with email){{ end }} asks:
+ + {{ .Created.Format "January 2, 2006 @ 15:04 MST" }} by + +

+

+ {{ .Question }} +

+ +
+
+ + + + +
+ + +
+
+
+ + +
+ {{ end }} +
+
+{{ end }} {{ end }} diff --git a/src/controllers/questions/questions.go b/src/controllers/questions/questions.go index ab4b8f7..7cdb41e 100644 --- a/src/controllers/questions/questions.go +++ b/src/controllers/questions/questions.go @@ -6,51 +6,46 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" "github.com/gorilla/mux" + "github.com/kirsle/blog/models/posts" + "github.com/kirsle/blog/models/settings" "github.com/kirsle/blog/src/log" "github.com/kirsle/blog/src/mail" "github.com/kirsle/blog/src/markdown" + "github.com/kirsle/blog/src/middleware/auth" + "github.com/kirsle/blog/src/models" "github.com/kirsle/blog/src/render" "github.com/kirsle/blog/src/responses" "github.com/kirsle/blog/src/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" + "github.com/urfave/negroni" ) var badRequest func(http.ResponseWriter, *http.Request, string) // Register the comment routes to the app. -func Register(r *mux.Router) { +func Register(r *mux.Router, loginError http.HandlerFunc) { 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 + r.Handle("/ask/answer", + negroni.New( + negroni.HandlerFunc(auth.LoginRequired(loginError)), + negroni.WrapFunc(answerHandler), + ), + ).Methods(http.MethodPost) } 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 := models.NewQuestion() Q.Name = name Q.Email = email @@ -67,71 +62,165 @@ func questionsHandler(w http.ResponseWriter, r *http.Request) { 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) + 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!") + // 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 } - case "answer": - case "delete": - default: - responses.FlashAndRedirect(w, r, "/ask", "Unknown submit action.") + + // 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\nAnswer 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 } } v["Q"] = Q + // Load the pending questions. + pending, err := models.PendingQuestions(0, 20) + if err != nil { + log.Error(err.Error()) + } + v["Pending"] = pending + render.Template(w, r, "questions.gohtml", v) } + +func answerHandler(w http.ResponseWriter, r *http.Request) { + submit := r.FormValue("submit") + + cfg, err := settings.Load() + if err != nil { + responses.Error(w, r, "Error loading site configuration!") + return + } + + type answerForm struct { + ID int + Answer string + Submit string + } + id, _ := strconv.Atoi(r.FormValue("id")) + form := answerForm{ + ID: id, + Answer: r.FormValue("answer"), + Submit: r.FormValue("submit"), + } + + // Look up the question. + Q, err := models.GetQuestion(form.ID) + if err != nil { + responses.FlashAndRedirect(w, r, "/ask", + fmt.Sprintf("Did not find question ID %d", form.ID), + ) + return + } + + switch submit { + case "answer": + // Prepare a Markdown-themed blog post and go to the Preview page for it. + blog := posts.New() + blog.Title = "Ask" + blog.Tags = []string{"ask"} + blog.Fragment = fmt.Sprintf("ask-%s", + time.Now().Format("20060102150405"), + ) + blog.Body = fmt.Sprintf( + "> **%s** asks:\n>\n> %s\n\n"+ + "%s\n", + Q.Name, + strings.Replace(Q.Question, "\n", "> \n", 0), + form.Answer, + ) + + Q.Status = models.Answered + Q.Save() + + // TODO: email the person who asked about the new URL. + if Q.Email != "" { + log.Info("Notifying user %s by email that the question is answered", Q.Email) + go mail.SendEmail(mail.Email{ + To: Q.Email, + Subject: "Your question has been answered", + Template: ".email/generic.gohtml", + Data: map[string]interface{}{ + "Subject": "Your question has been answered", + "Message": template.HTML( + markdown.RenderMarkdown( + fmt.Sprintf( + "Hello, %s\n\n"+ + "Your recent question on %s has been answered. To "+ + "view the answer, please visit the following link:\n\n"+ + "%s/%s", + Q.Name, + cfg.Site.Title, + cfg.Site.URL, + blog.Fragment, + ), + ), + ), + }, + }) + } + + render.Template(w, r, "blog/edit", map[string]interface{}{ + "preview": template.HTML(markdown.RenderTrustedMarkdown(blog.Body)), + "post": blog, + }) + return + case "delete": + Q.Status = models.Deleted + Q.Save() + responses.FlashAndRedirect(w, r, "/ask", "Question deleted.") + return + default: + responses.FlashAndRedirect(w, r, "/ask", "Unknown submit action.") + return + } +} diff --git a/src/models/models.go b/src/models/models.go new file mode 100644 index 0000000..cee1e6c --- /dev/null +++ b/src/models/models.go @@ -0,0 +1,21 @@ +package models + +import ( + "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") +} diff --git a/models/questions/questions.go b/src/models/questions.go similarity index 76% rename from models/questions/questions.go rename to src/models/questions.go index e263998..acbde83 100644 --- a/models/questions/questions.go +++ b/src/models/questions.go @@ -1,4 +1,4 @@ -package questions +package models import ( "errors" @@ -6,26 +6,8 @@ import ( "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"` @@ -37,17 +19,34 @@ type Question struct { Updated time.Time `json:"updated"` } -// New creates a blank Question with sensible defaults. -func New() *Question { +// NewQuestion creates a blank Question with sensible defaults. +func NewQuestion() *Question { return &Question{ Status: Pending, } } -// All returns all the Questions. -func All() ([]*Question, error) { +// GetQuestion by its ID. +func GetQuestion(id int) (*Question, error) { + result := &Question{} + err := DB.First(&result, id).Error + return result, err +} + +// AllQuestions returns all the Questions. +func AllQuestions() ([]*Question, error) { result := []*Question{} - err := DB.Order("start_time desc").Find(&result).Error + err := DB.Order("created desc").Find(&result).Error + return result, err +} + +// PendingQuestions returns pending questions in order of recency. +func PendingQuestions(offset, limit int) ([]*Question, error) { + result := []*Question{} + err := DB.Where("status = ?", Pending). + Offset(offset).Limit(limit). + Order("created desc"). + Find(&result).Error return result, err } diff --git a/models/questions/enums.go b/src/models/questions_types.go similarity index 88% rename from models/questions/enums.go rename to src/models/questions_types.go index 74889fa..6aef7d3 100644 --- a/models/questions/enums.go +++ b/src/models/questions_types.go @@ -1,4 +1,4 @@ -package questions +package models // Status of a Question. type Status string