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
This commit is contained in:
Noah 2018-12-24 11:47:25 -08:00
parent 9b938ccff3
commit 1821ef60d4
6 changed files with 309 additions and 160 deletions

22
blog.go
View File

@ -9,6 +9,14 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/jinzhu/gorm" "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/admin"
"github.com/kirsle/blog/src/controllers/authctl" "github.com/kirsle/blog/src/controllers/authctl"
commentctl "github.com/kirsle/blog/src/controllers/comments" commentctl "github.com/kirsle/blog/src/controllers/comments"
@ -20,18 +28,10 @@ import (
"github.com/kirsle/blog/src/markdown" "github.com/kirsle/blog/src/markdown"
"github.com/kirsle/blog/src/middleware" "github.com/kirsle/blog/src/middleware"
"github.com/kirsle/blog/src/middleware/auth" "github.com/kirsle/blog/src/middleware/auth"
"github.com/kirsle/blog/src/models"
"github.com/kirsle/blog/src/render" "github.com/kirsle/blog/src/render"
"github.com/kirsle/blog/src/responses" "github.com/kirsle/blog/src/responses"
"github.com/kirsle/blog/src/sessions" "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/shurcooL/github_flavored_markdown/gfmstyle"
"github.com/urfave/negroni" "github.com/urfave/negroni"
) )
@ -106,7 +106,7 @@ func (b *Blog) Configure() {
posts.DB = b.jsonDB posts.DB = b.jsonDB
users.DB = b.jsonDB users.DB = b.jsonDB
comments.DB = b.jsonDB comments.DB = b.jsonDB
questions.UseDB(b.db) models.UseDB(b.db)
// Redis cache? // Redis cache?
if config.Redis.Enabled { if config.Redis.Enabled {
@ -139,7 +139,7 @@ func (b *Blog) SetupHTTP() {
contact.Register(r) contact.Register(r)
postctl.Register(r, b.MustLogin) postctl.Register(r, b.MustLogin)
commentctl.Register(r) commentctl.Register(r)
questionsctl.Register(r) questionsctl.Register(r, b.MustLogin)
// GitHub Flavored Markdown CSS. // GitHub Flavored Markdown CSS.
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))

View File

@ -8,60 +8,100 @@
</div> </div>
{{ end }} {{ end }}
<form name="askme" method="POST" action="/ask"> <div class="card">
<div class="card"> <div class="card-body">
<div class="card-body"> <form name="askme" method="POST" action="/ask">
<input type="hidden" name="_csrf" value="{{ .CSRF }}"> <input type="hidden" name="_csrf" value="{{ .CSRF }}">
<div class="form-group row"> <div class="form-group row">
<div class="col-12 col-md-2"> <div class="col-12 col-md-2">
<label class="col-form-label" for="name">Name:</label> <label class="col-form-label" for="name">Name:</label>
</div>
<div class="col-12 col-md-10">
<input type="text" class="form-control" id="name" name="name" value="{{ .Data.Q.Name }}" placeholder="Anonymous">
</div>
</div> </div>
<div class="form-group row"> <div class="col-12 col-md-10">
<div class="col-12 col-md-2"> <input type="text" class="form-control" id="name" name="name" value="{{ .Data.Q.Name }}" placeholder="Anonymous">
<label class="col-form-label" for="email">Email:</label>
</div>
<div class="col-12 col-md-10">
<div><input type="email" class="form-control" id="email" name="email" value="{{ .Data.Q.Email }}" placeholder="name@example.com"></div>
<small>
Optional. You will receive a one-time e-mail when I answer your question and no spam.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-12" for="question">Question: <small>(required)</small></label>
<textarea cols="80" rows="6"
class="col-12 form-control"
name="question"
id="question"
placeholder="Ask me anything">{{ .Data.Q.Question }}</textarea>
</div>
<div class="form-group row">
<div class="col">
<button type="submit"
name="submit"
value="ask"
class="btn btn-primary">Ask away!</button>
</div>
</div> </div>
</div> </div>
<div class="form-group row">
<div class="col-12 col-md-2">
<label class="col-form-label" for="email">Email:</label>
</div>
<div class="col-12 col-md-10">
<div><input type="email" class="form-control" id="email" name="email" value="{{ .Data.Q.Email }}" placeholder="name@example.com"></div>
<small>
Optional. You will receive a one-time e-mail when I answer your question and no spam.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-12" for="question">Question: <small>(required)</small></label>
<textarea cols="80" rows="6"
class="col-12 form-control"
name="question"
id="question"
placeholder="Ask me anything">{{ .Data.Q.Question }}</textarea>
</div>
<div class="form-group row">
<div class="col">
<button type="submit"
name="submit"
value="ask"
class="btn btn-primary">Ask away!</button>
</div>
</div>
</form>
</div> </div>
</div>
{{ if .LoggedIn }} {{ if .LoggedIn }}
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"> <div class="card-header">
Pending Questions Pending Questions
</div>
<div class="card-body">
xxx
</div>
</div> </div>
{{ end }} <div class="card-body">
</form> {{ if not .Data.Pending }}
<em>There are no pending questions.</em>
{{ end }}
{{ range .Data.Pending }}
<p>
<strong>{{ .Name }}</strong> {{ if .Email }}(with email){{ end }} asks:<br>
<small class="text-muted">
<em>{{ .Created.Format "January 2, 2006 @ 15:04 MST" }}</em> by
</small>
</p>
<p>
{{ .Question }}
</p>
<div id="form-{{ .ID }}" class="dhtml-forms">
<form method="POST" action="/ask/answer">
<input type="hidden" name="_csrf" value="{{ $.CSRF }}">
<input type="hidden" name="id" value="{{ .ID }}">
<textarea cols="80" rows="4"
class="form-control"
name="answer"
placeholder="Answer (Markdown formatting allowed)"></textarea>
<div class="btn-group mt-3">
<button type="submit" name="submit" value="answer" class="btn btn-primary">
Answer
</button>
<button type="submit" name="submit" value="delete" class="btn btn-danger">
Delete
</button>
</div>
</form>
</div>
<div id="button-{{ .ID }}" class="dhtml-buttons" style="display: none">
<button type="button" class="btn" id="show-{{ .ID }}" class="dhtml-show-button">Answer or delete</button>
</div>
<hr>
{{ end }}
</div>
</div>
{{ end }}
{{ end }} {{ end }}

View File

@ -6,51 +6,46 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
"github.com/gorilla/mux" "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/log"
"github.com/kirsle/blog/src/mail" "github.com/kirsle/blog/src/mail"
"github.com/kirsle/blog/src/markdown" "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/render"
"github.com/kirsle/blog/src/responses" "github.com/kirsle/blog/src/responses"
"github.com/kirsle/blog/src/sessions" "github.com/kirsle/blog/src/sessions"
"github.com/kirsle/blog/models/comments" "github.com/urfave/negroni"
"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) var badRequest func(http.ResponseWriter, *http.Request, string)
// Register the comment routes to the app. // Register the comment routes to the app.
func Register(r *mux.Router) { func Register(r *mux.Router, loginError http.HandlerFunc) {
badRequest = responses.BadRequest badRequest = responses.BadRequest
r.HandleFunc("/ask", questionsHandler) r.HandleFunc("/ask", questionsHandler)
} r.Handle("/ask/answer",
negroni.New(
// CommentMeta is the template variables for comment threads. negroni.HandlerFunc(auth.LoginRequired(loginError)),
type CommentMeta struct { negroni.WrapFunc(answerHandler),
NewComment comments.Comment ),
ID string ).Methods(http.MethodPost)
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) { func questionsHandler(w http.ResponseWriter, r *http.Request) {
submit := r.FormValue("submit")
// Share their name and email with the commenting system. // Share their name and email with the commenting system.
session := sessions.Get(r) session := sessions.Get(r)
name, _ := session.Values["c.name"].(string) name, _ := session.Values["c.name"].(string)
email, _ := session.Values["c.email"].(string) email, _ := session.Values["c.email"].(string)
Q := questions.New() Q := models.NewQuestion()
Q.Name = name Q.Name = name
Q.Email = email Q.Email = email
@ -67,71 +62,165 @@ func questionsHandler(w http.ResponseWriter, r *http.Request) {
Q.ParseForm(r) Q.ParseForm(r)
log.Info("Q: %+v", Q) log.Info("Q: %+v", Q)
switch submit { if err := Q.Validate(); err != nil {
case "ask": log.Debug("Validation error on question form: %s", err.Error())
if err := Q.Validate(); err != nil { v["Error"] = err
log.Debug("Validation error on question form: %s", err.Error()) } else {
v["Error"] = err // Cache their name and email in their session.
} else { session.Values["c.name"] = Q.Name
// Cache their name and email in their session. session.Values["c.email"] = Q.Email
session.Values["c.name"] = Q.Name session.Save(r, w)
session.Values["c.email"] = Q.Email
session.Save(r, w)
// Append their comment. // Append their comment.
err := Q.Save() err := Q.Save()
if err != nil { if err != nil {
log.Error("Error saving new question: %s", err.Error()) log.Error("Error saving new question: %s", err.Error())
responses.FlashAndRedirect(w, r, "/ask", "Error saving question: %s", err) 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 return
} }
case "answer":
case "delete": // Email the site admin.
default: subject := fmt.Sprintf("Ask Me Anything (%s) from %s", cfg.Site.Title, Q.Name)
responses.FlashAndRedirect(w, r, "/ask", "Unknown submit action.") 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 return
} }
} }
v["Q"] = Q 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) 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
}
}

21
src/models/models.go Normal file
View File

@ -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")
}

View File

@ -1,4 +1,4 @@
package questions package models
import ( import (
"errors" "errors"
@ -6,26 +6,8 @@ import (
"net/mail" "net/mail"
"strconv" "strconv"
"time" "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. // Question is a question asked of the blog owner.
type Question struct { type Question struct {
ID int `json:"id"` ID int `json:"id"`
@ -37,17 +19,34 @@ type Question struct {
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
} }
// New creates a blank Question with sensible defaults. // NewQuestion creates a blank Question with sensible defaults.
func New() *Question { func NewQuestion() *Question {
return &Question{ return &Question{
Status: Pending, Status: Pending,
} }
} }
// All returns all the Questions. // GetQuestion by its ID.
func All() ([]*Question, error) { 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{} 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 return result, err
} }

View File

@ -1,4 +1,4 @@
package questions package models
// Status of a Question. // Status of a Question.
type Status string type Status string