Ask Me Anything and Contact Me pages

This commit is contained in:
Noah 2020-02-17 19:40:57 -08:00
parent 5b6712ea97
commit bf86ceb585
9 changed files with 575 additions and 6 deletions

View File

@ -0,0 +1,90 @@
package controllers
import (
"fmt"
"html/template"
"net/http"
"git.kirsle.net/apps/gophertype/pkg/glue"
"git.kirsle.net/apps/gophertype/pkg/mail"
"git.kirsle.net/apps/gophertype/pkg/markdown"
"git.kirsle.net/apps/gophertype/pkg/responses"
"git.kirsle.net/apps/gophertype/pkg/session"
"git.kirsle.net/apps/gophertype/pkg/settings"
"github.com/albrow/forms"
)
func init() {
glue.Register(glue.Endpoint{
Path: "/contact",
Methods: []string{"GET", "POST"},
Handler: ContactHandler,
})
}
// ContactHandler receives admin emails from users.
func ContactHandler(w http.ResponseWriter, r *http.Request) {
var (
v = responses.NewTemplateVars(w, r)
ses = session.Get(r)
)
// Load their cached name from any previous comments they may have posted.
name, _ := ses.Values["c.name"].(string)
email, _ := ses.Values["c.email"].(string)
for r.Method == http.MethodPost {
// Validate form parameters.
form, _ := forms.Parse(r)
val := form.Validator()
val.Require("message")
if val.HasErrors() {
v.ValidationError = val.ErrorMap()
v.V["Error"] = "Missing required form fields."
break
}
var (
name = form.Get("name")
email = form.Get("email")
subject = form.Get("subject")
message = form.Get("message")
)
// Cache their name in their session for future comments/asks.
ses.Values["c.name"] = name
ses.Values["c.email"] = email
ses.Save(r, w)
// Email the site admin.
if name == "" {
name = "Anonymous"
}
if subject == "" {
subject = "No Subject"
}
go mail.EmailAdmins(mail.Email{
Subject: fmt.Sprintf("Contact Me (%s) from %s: %s", settings.Current.Title, name, subject),
Template: "_builtin/email/generic.gohtml",
Data: map[string]interface{}{
"Subject": subject,
"ReplyTo": email,
"Message": template.HTML(
markdown.RenderMarkdown(fmt.Sprintf(
"Subject: %s\nSender name: %s\nSender email: %s\n\n%s\n",
subject, name, email, message,
)),
),
},
})
session.Flash(w, r, "Thank you! Your message has been e-mailed to the site owner.")
responses.Redirect(w, r, "/contact")
return
}
v.V["name"] = name
v.V["email"] = email
responses.RenderTemplate(w, r, "_builtin/contact.gohtml", v)
}

View File

@ -0,0 +1,193 @@
package controllers
import (
"fmt"
"html/template"
"net/http"
"strings"
"time"
"git.kirsle.net/apps/gophertype/pkg/authentication"
"git.kirsle.net/apps/gophertype/pkg/console"
"git.kirsle.net/apps/gophertype/pkg/glue"
"git.kirsle.net/apps/gophertype/pkg/mail"
"git.kirsle.net/apps/gophertype/pkg/markdown"
"git.kirsle.net/apps/gophertype/pkg/models"
"git.kirsle.net/apps/gophertype/pkg/responses"
"git.kirsle.net/apps/gophertype/pkg/session"
"git.kirsle.net/apps/gophertype/pkg/settings"
"github.com/albrow/forms"
"github.com/gorilla/mux"
"github.com/kirsle/blog/src/log"
)
func init() {
glue.Register(glue.Endpoint{
Path: "/ask",
Methods: []string{"GET", "POST"},
Handler: QuestionHandler,
})
glue.Register(glue.Endpoint{
Path: "/ask/answer",
Methods: []string{"POST"},
Middleware: []mux.MiddlewareFunc{
authentication.LoginRequired,
},
Handler: AnswerHandler,
})
}
// QuestionHandler implements the "Ask Me Anything" at the URL "/ask"
func QuestionHandler(w http.ResponseWriter, r *http.Request) {
var (
v = responses.NewTemplateVars(w, r)
ses = session.Get(r)
)
// Load their cached name from any previous comments they may have posted.
name, _ := ses.Values["c.name"].(string)
q := models.Questions.New()
q.Name = name
for r.Method == http.MethodPost {
form, _ := forms.Parse(r)
q.ParseForm(r)
// Validate form parameters.
val := form.Validator()
val.Require("question")
if val.HasErrors() {
v.ValidationError = val.ErrorMap()
v.V["Error"] = "Missing required form fields."
break
}
// Cache their name in their session for future comments/asks.
ses.Values["c.name"] = q.Name
ses.Save(r, w)
// Save the question.
err := q.Save()
if err != nil {
log.Error("Error saving neq eustion: %s", err)
responses.Error(w, r, http.StatusInternalServerError, "Error saving question: "+err.Error())
return
}
// Email the site admin.
if name == "" {
name = "Anonymous"
}
subject := fmt.Sprintf("Ask Me Anything (%s) from %s", settings.Current.Title, name)
go mail.EmailAdmins(mail.Email{
Subject: subject,
Template: "_builtin/email/generic.gohtml",
Data: map[string]interface{}{
"Subject": subject,
"Message": template.HTML(
markdown.RenderMarkdown(fmt.Sprintf(
"%s\n\nAnswer this at %s",
q.Question,
strings.TrimSuffix(settings.Current.BaseURL, "/")+"/ask",
)),
),
},
})
session.Flash(w, r, "Your question has been recorded!")
responses.Redirect(w, r, "/ask")
return
}
// If logged in, load the pending questions.
if authentication.LoggedIn(r) {
pending, err := models.Questions.Pending()
if err != nil {
console.Error("Error loading pending questions: %s", err)
}
v.V["Pending"] = pending
}
// Load the recently answered questions for public users.
recent, err := models.Questions.RecentlyAnswered(10)
if err != nil {
console.Error("Error loading recently answered questions: %s", err)
}
v.V["Q"] = q
v.V["Recent"] = recent
responses.RenderTemplate(w, r, "_builtin/questions.gohtml", v)
}
// AnswerHandler handles answering (and deleting) questions.
func AnswerHandler(w http.ResponseWriter, r *http.Request) {
v := responses.NewTemplateVars(w, r)
CurrentUser, _ := authentication.CurrentUser(r)
// Validate form parameters.
form, _ := forms.Parse(r)
val := form.Validator()
val.Require("id")
val.Require("answer")
val.Require("submit")
if val.HasErrors() {
v.ValidationError = val.ErrorMap()
v.V["Error"] = "Missing required form fields."
responses.RenderTemplate(w, r, "_builtin/questions.gohtml", v)
return
}
// Look up the question.
q, err := models.Questions.Load(form.GetInt("id"))
if err != nil {
responses.Error(w, r, http.StatusInternalServerError, err.Error())
return
}
// Handle submit actions.
switch form.Get("submit") {
case "answer":
// Prepare a Markdown themed blog post for this answer.
name := q.Name
if name == "" {
name = "Anonymous"
}
post := models.Post{
Title: "Ask",
ContentType: "markdown",
Privacy: models.Public,
EnableComments: true,
AuthorID: CurrentUser.ID,
Tags: []models.TaggedPost{
models.TaggedPost{Tag: "ask"},
},
Fragment: fmt.Sprintf("ask-%s",
time.Now().Format("20060102150405"),
),
Body: fmt.Sprintf(
"> **%s** asks:\n\n> %s\n\n%s\n",
name,
strings.Replace(q.Question, "\n", "\n> ", 0),
form.Get("answer"),
),
}
post.Save()
// Associate the question to this post ID.
q.PostID = post.ID
q.Answered = true
q.Save()
// Send the admin to the post edit page.
responses.Redirect(w, r, "/"+post.Fragment)
case "delete":
q.Delete()
session.Flash(w, r, "Question deleted!")
responses.Redirect(w, r, "/ask")
default:
responses.BadRequest(w, r, "Invalid submit method.")
}
}

View File

@ -103,6 +103,16 @@ func SendEmail(email Email) {
}
}
// EmailAdmins sends an e-mail to all admin user email addresses.
func EmailAdmins(email Email) {
if adminEmails, err := models.Users.ListAdminEmails(); err == nil {
email.To = strings.Join(adminEmails, ", ")
email.Admin = true
console.Info("Mail site admin '%s' about email '%s'", email.To, email.Subject)
SendEmail(email)
}
}
// NotifyComment sends notification emails about comments.
func NotifyComment(subject string, originURL string, c models.Comment) {
s := settings.Current

View File

@ -19,8 +19,5 @@ type BaseModel struct {
// UseDB registers a database driver.
func UseDB(db *gorm.DB) {
DB = db
DB.AutoMigrate(&User{})
DB.Debug().AutoMigrate(&Post{})
DB.AutoMigrate(&TaggedPost{})
DB.AutoMigrate(&Comment{})
DB.AutoMigrate(&User{}, &Post{}, &TaggedPost{}, &Comment{}, &Question{})
}

70
pkg/models/questions.go Normal file
View File

@ -0,0 +1,70 @@
package models
import "net/http"
type askMan struct{}
// Questions is a singleton manager class for Question model access.
var Questions = askMan{}
// Question model.
type Question struct {
BaseModel
Name string
Question string
Answered bool
PostID int // FKey Post.id
// Relationships.
Post Post
}
// New creates a new Question model.
func (m askMan) New() Question {
return Question{}
}
// Load a comment by ID.
func (m askMan) Load(id int) (Question, error) {
var q Question
r := DB.Preload("Post").First(&q, id)
return q, r.Error
}
// Pending returns questions in need of answering.
func (m askMan) Pending() ([]Question, error) {
var q []Question
r := DB.Preload("Post").Where("answered=false").Find(&q)
return q, r.Error
}
// RecentlyAnswered returns questions that have blog posts attached.
func (m askMan) RecentlyAnswered(depth int) ([]Question, error) {
var qq []Question
r := DB.Preload("Post").
Where("answered=true AND post_id IS NOT NULL").
Order("created_at desc").
Limit(depth).
Find(&qq)
return qq, r.Error
}
// Save the question.
func (q Question) Save() error {
if DB.NewRecord(q) {
return DB.Create(&q).Error
}
return DB.Save(&q).Error
}
// Delete the question.
func (q Question) Delete() error {
return DB.Delete(&q).Error
}
// ParseForm sets the question's attributes from HTTP form.
func (q *Question) ParseForm(r *http.Request) {
q.Name = r.FormValue("name")
q.Question = r.FormValue("question")
}

View File

@ -43,8 +43,6 @@
</div>
</div>
{{ .CurrentUser }}
{{ if .CurrentUser.IsAdmin }}
<div class="alert alert-secondary">
<strong>Admin:</strong>

View File

@ -0,0 +1,53 @@
{{ define "title" }}Contact Me{{ end }}
{{ define "content" }}
<h1>Contact Me</h1>
<div class="card">
<div class="card-body">
<p>
Fill out this form to send an e-mail to the site owner.
</p>
<form action="/contact" method="POST">
{{ CSRF }}
<div class="form-row">
<div class="form-group col-md-6">
<label for="name">Your name:</label>
<input type="text" class="form-control" id="name" placeholder="Anonymous" value="{{ .V.name }}">
</div>
<div class="form-group col-md-6">
<label for="name">Your email:</label>
<input type="email" class="form-control" id="email" placeholder="name@example.com" value="{{ .V.email }}">
<small id="email-help" class="form-text text-muted">
If you want a response; optional.
</small>
</div>
</div>
<div class="form-row">
<div class="form-group col-12">
<label for="subject">Subject</label>
<input type="text" class="form-control" name="subject" placeholder="No Subject">
</div>
</div>
<div class="form-row">
<div class="form-group col-12">
<label for="message" title="Required">Message<span class="text-danger">*</span></label>
<textarea cols="80" rows="10" class="form-control" name="message" id="message" required="required"></textarea>
</div>
</div>
<div class="form-row">
<div class="form-group col-12">
<button type="submit" class="btn btn-primary">Send</button>
</div>
</div>
</form>
</div>
</div>
{{ end }}

View 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>

View File

@ -0,0 +1,119 @@
{{ define "title" }}Ask Me Anything{{ end }}
{{ define "content" }}
{{ $Q := .V.Q }}
<div class="card">
<div class="card-body">
<form action="/ask" method="POST">
{{ 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="{{ $Q.Name }}" placeholder="Anonymous">
</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">{{ $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>
{{ if gt (len .V.Recent) 0 }}
<div class="card mt-4">
<div class="card-header">
Recently Answered
</div>
<div class="card-body">
{{ range .V.Recent }}
<strong>{{ or .Name "Anonymous" }}</strong> asks:
<p>{{ .Question }}</p>
<p>
<a href="/{{ .Post.Fragment }}">Read answer&gt;</a>
</p>
<p class="blog-meta">
<em title="{{ .CreatedAt.Format "Jan 2 2006 15:04:05 MST" }}">
{{ .CreatedAt.Format "January 2, 2006" }}
</em>
</p>
<hr class="my-4">
{{ end }}
<p>
<a href="/tagged/ask">&rarr; More questions &amp; answers</a>
</p>
</div>
</div>
{{ end }}
{{ if .LoggedIn }}
<div class="card mt-4">
<div class="card-header bg-secondary text-light">
Pending Questions
</div>
<div class="card-body">
{{ if not .V.Pending }}
<em>There are no pending questions.</em>
{{ end }}
{{ range .V.Pending }}
<p>
<strong>{{ or .Name "Anonymous" }}</strong> asks:<br>
<small class="text-muted">
<em>{{ .CreatedAt.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">
{{ CSRF }}
<input type="hidden" name="id" value="{{ .ID }}">
<textarea cols="80" rows="4"
class="form-control"
name="answer"
required="required"
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 }}{# if .LoggedIn #}
{{ end }}