Questions system WIP
This commit is contained in:
parent
eae499c640
commit
517a2ee86b
4
blog.go
4
blog.go
|
@ -14,6 +14,7 @@ import (
|
||||||
commentctl "github.com/kirsle/blog/internal/controllers/comments"
|
commentctl "github.com/kirsle/blog/internal/controllers/comments"
|
||||||
"github.com/kirsle/blog/internal/controllers/contact"
|
"github.com/kirsle/blog/internal/controllers/contact"
|
||||||
postctl "github.com/kirsle/blog/internal/controllers/posts"
|
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/controllers/setup"
|
||||||
"github.com/kirsle/blog/internal/log"
|
"github.com/kirsle/blog/internal/log"
|
||||||
"github.com/kirsle/blog/internal/markdown"
|
"github.com/kirsle/blog/internal/markdown"
|
||||||
|
@ -28,6 +29,7 @@ import (
|
||||||
"github.com/kirsle/blog/jsondb/caches/redis"
|
"github.com/kirsle/blog/jsondb/caches/redis"
|
||||||
"github.com/kirsle/blog/models/comments"
|
"github.com/kirsle/blog/models/comments"
|
||||||
"github.com/kirsle/blog/models/posts"
|
"github.com/kirsle/blog/models/posts"
|
||||||
|
"github.com/kirsle/blog/models/questions"
|
||||||
"github.com/kirsle/blog/models/settings"
|
"github.com/kirsle/blog/models/settings"
|
||||||
"github.com/kirsle/blog/models/users"
|
"github.com/kirsle/blog/models/users"
|
||||||
"github.com/shurcooL/github_flavored_markdown/gfmstyle"
|
"github.com/shurcooL/github_flavored_markdown/gfmstyle"
|
||||||
|
@ -104,6 +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)
|
||||||
|
|
||||||
// Redis cache?
|
// Redis cache?
|
||||||
if config.Redis.Enabled {
|
if config.Redis.Enabled {
|
||||||
|
@ -136,6 +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)
|
||||||
|
|
||||||
// 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)))
|
||||||
|
|
137
internal/controllers/questions/questions.go
Normal file
137
internal/controllers/questions/questions.go
Normal file
|
@ -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)
|
||||||
|
}
|
16
internal/controllers/questions/questions_doc.go
Normal file
16
internal/controllers/questions/questions_doc.go
Normal file
|
@ -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
|
11
models/questions/enums.go
Normal file
11
models/questions/enums.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package questions
|
||||||
|
|
||||||
|
// Status of a Question.
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
// Status options.
|
||||||
|
const (
|
||||||
|
Pending = "pending"
|
||||||
|
Answered = "answered"
|
||||||
|
Deleted = "deleted"
|
||||||
|
)
|
126
models/questions/questions.go
Normal file
126
models/questions/questions.go
Normal file
|
@ -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
|
||||||
|
}
|
39
root/.email/generic.gohtml
Normal file
39
root/.email/generic.gohtml
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
|
||||||
|
<title>{{ .Subject }}</title>
|
||||||
|
</head>
|
||||||
|
<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 0; mso-line-height-rule: exactly;">
|
||||||
|
|
||||||
|
<center>
|
||||||
|
<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" bgcolor="#C0C0C0">
|
||||||
|
<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
|
||||||
|
<b>{{ .Subject }}</b>
|
||||||
|
</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" bgcolor="#FEFEFE">
|
||||||
|
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
|
||||||
|
{{ .Data.Message }}
|
||||||
|
</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" bgcolor="#C0C0C0">
|
||||||
|
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
|
||||||
|
This e-mail was automatically generated; do not reply to it.
|
||||||
|
</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
67
root/questions.gohtml
Normal file
67
root/questions.gohtml
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
{{ define "title" }}Ask Me Anything{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<h1>Ask Me Anything</h1>
|
||||||
|
|
||||||
|
{{ if .Data.Error }}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
Error: {{ .Data.Error }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<form name="askme" method="POST" action="/ask">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-12 col-md-2">
|
||||||
|
<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 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .LoggedIn }}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Pending Questions
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
xxx
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ end }}
|
Loading…
Reference in New Issue
Block a user