@@ -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) | |||
} |
@@ -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.") | |||
} | |||
} |
@@ -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 | |||
@@ -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{}) | |||
} |
@@ -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") | |||
} |
@@ -43,8 +43,6 @@ | |||
</div> | |||
</div> | |||
{{ .CurrentUser }} | |||
{{ if .CurrentUser.IsAdmin }} | |||
<div class="alert alert-secondary"> | |||
<strong>Admin:</strong> | |||
@@ -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 }} |
@@ -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> |
@@ -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></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">→ More questions & 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 }} |