Blog tag pages and contact form

This commit is contained in:
Noah 2017-12-22 18:34:58 -08:00
parent d730b4d43c
commit 94cdc916ac
18 changed files with 345 additions and 33 deletions

View File

@ -44,7 +44,12 @@ func main() {
app := core.New(DocumentRoot, userRoot) app := core.New(DocumentRoot, userRoot)
if fDebug { if fDebug {
app.Debug = true app.Debug = true
}
// Set $JSONDB_DEBUG=1 to debug JsonDB; it's very noisy!
if os.Getenv("JSONDB_DEBUG") != "" {
jsondb.SetDebug(true) jsondb.SetDebug(true)
} }
app.ListenAndServe(fAddress) app.ListenAndServe(fAddress)
} }

View File

@ -62,6 +62,7 @@ func New(documentRoot, userRoot string) *Blog {
r.HandleFunc("/login", blog.LoginHandler) r.HandleFunc("/login", blog.LoginHandler)
r.HandleFunc("/logout", blog.LogoutHandler) r.HandleFunc("/logout", blog.LogoutHandler)
blog.AdminRoutes(r) blog.AdminRoutes(r)
blog.ContactRoutes(r)
blog.BlogRoutes(r) blog.BlogRoutes(r)
blog.CommentRoutes(r) blog.CommentRoutes(r)

View File

@ -40,6 +40,7 @@ func (b *Blog) BlogRoutes(r *mux.Router) {
// Public routes // Public routes
r.HandleFunc("/blog", b.IndexHandler) r.HandleFunc("/blog", b.IndexHandler)
r.HandleFunc("/archive", b.BlogArchive) r.HandleFunc("/archive", b.BlogArchive)
r.HandleFunc("/tagged", b.Tagged)
r.HandleFunc("/tagged/{tag}", b.Tagged) r.HandleFunc("/tagged/{tag}", b.Tagged)
// Login-required routers. // Login-required routers.
@ -75,7 +76,9 @@ func (b *Blog) Tagged(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r) params := mux.Vars(r)
tag, ok := params["tag"] tag, ok := params["tag"]
if !ok { if !ok {
b.BadRequest(w, r, "Missing category in URL") // They're listing all the tags.
b.RenderTemplate(w, r, "blog/tags.gohtml", NewVars())
return
} }
b.CommonIndexHandler(w, r, tag, "") b.CommonIndexHandler(w, r, tag, "")
@ -199,20 +202,6 @@ func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML {
log.Error("couldn't load full post data for ID %d (found in index.json)", pool[i].ID) log.Error("couldn't load full post data for ID %d (found in index.json)", pool[i].ID)
continue continue
} }
var rendered template.HTML
// Body has a snipped section?
if strings.Contains(post.Body, "<snip>") {
parts := strings.SplitN(post.Body, "<snip>", 1)
post.Body = parts[0]
}
// Render the post.
if post.ContentType == string(MARKDOWN) {
rendered = template.HTML(b.RenderTrustedMarkdown(post.Body))
} else {
rendered = template.HTML(post.Body)
}
// Look up the author's information. // Look up the author's information.
author, err := users.LoadReadonly(post.AuthorID) author, err := users.LoadReadonly(post.AuthorID)
@ -229,7 +218,6 @@ func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML {
view = append(view, PostMeta{ view = append(view, PostMeta{
Post: post, Post: post,
Rendered: rendered,
Author: author, Author: author,
NumComments: numComments, NumComments: numComments,
}) })
@ -247,6 +235,31 @@ func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML {
return template.HTML(output.String()) return template.HTML(output.String())
} }
// RenderTags renders the tags partial.
func (b *Blog) RenderTags(r *http.Request, indexView bool) template.HTML {
idx, err := posts.GetIndex()
if err != nil {
return template.HTML("[RenderTags: error getting blog index]")
}
tags, err := idx.Tags()
if err != nil {
return template.HTML("[RenderTags: error getting tags]")
}
var output bytes.Buffer
v := struct {
IndexView bool
Tags []posts.Tag
}{
IndexView: indexView,
Tags: tags,
}
b.RenderPartialTemplate(&output, "blog/tags.partial", v, false, nil)
return template.HTML(output.String())
}
// BlogArchive summarizes all blog entries in an archive view. // BlogArchive summarizes all blog entries in an archive view.
func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) { func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) {
idx, err := posts.GetIndex() idx, err := posts.GetIndex()
@ -266,7 +279,7 @@ func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) {
continue continue
} }
label := post.Created.Format("2006-02") label := post.Created.Format("2006-01")
if _, ok := byMonth[label]; !ok { if _, ok := byMonth[label]; !ok {
months = append(months, label) months = append(months, label)
byMonth[label] = &Archive{ byMonth[label] = &Archive{
@ -335,13 +348,14 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool, numComments int) templa
var snipped bool var snipped bool
if indexView { if indexView {
if strings.Contains(p.Body, "<snip>") { if strings.Contains(p.Body, "<snip>") {
log.Warn("HAS SNIP TAG!")
parts := strings.SplitN(p.Body, "<snip>", 2) parts := strings.SplitN(p.Body, "<snip>", 2)
p.Body = parts[0] p.Body = parts[0]
snipped = true snipped = true
} }
} }
p.Body = strings.Replace(p.Body, "<snip>", "<div id=\"snip\"></div>", 1)
// Render the post to HTML. // Render the post to HTML.
var rendered template.HTML var rendered template.HTML
if p.ContentType == string(MARKDOWN) { if p.ContentType == string(MARKDOWN) {

57
core/contact.go Normal file
View File

@ -0,0 +1,57 @@
package core
import (
"fmt"
"html/template"
"net/http"
"github.com/gorilla/mux"
"github.com/kirsle/blog/core/forms"
"github.com/kirsle/blog/core/models/settings"
)
// ContactRoutes attaches the contact URL to the app.
func (b *Blog) ContactRoutes(r *mux.Router) {
r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) {
v := NewVars()
form := forms.Contact{}
v.Form = &form
// If there is no site admin, show an error.
cfg, err := settings.Load()
if err != nil {
b.Error(w, r, "Error loading site configuration!")
return
} else if cfg.Site.AdminEmail == "" {
b.Error(w, r, "There is no admin email configured for this website!")
return
} else if !cfg.Mail.Enabled {
b.Error(w, r, "This website doesn't have an e-mail gateway configured.")
return
}
// Posting?
if r.Method == http.MethodPost {
form.ParseForm(r)
if err = form.Validate(); err != nil {
b.Flash(w, r, err.Error())
} else {
go b.SendEmail(Email{
To: cfg.Site.AdminEmail,
Admin: true,
ReplyTo: form.Email,
Subject: fmt.Sprintf("Contact Form on %s: %s", cfg.Site.Title, form.Subject),
Template: ".email/contact.gohtml",
Data: map[string]interface{}{
"Name": form.Name,
"Message": template.HTML(b.RenderMarkdown(form.Message)),
"Email": form.Email,
},
})
b.FlashAndRedirect(w, r, "/contact", "Your message has been sent.")
}
}
b.RenderTemplate(w, r, "contact", v)
})
}

42
core/forms/contact.go Normal file
View File

@ -0,0 +1,42 @@
package forms
import (
"errors"
"net/http"
)
// Contact form for the site admin.
type Contact struct {
Name string
Email string
Subject string
Message string
Trap1 string // 'contact'
Trap2 string // 'website'
}
// ParseForm parses the form.
func (c *Contact) ParseForm(r *http.Request) {
c.Name = r.FormValue("name")
c.Email = r.FormValue("email")
c.Subject = r.FormValue("subject")
c.Message = r.FormValue("message")
c.Trap1 = r.FormValue("contact")
c.Trap2 = r.FormValue("website")
// Default values.
if c.Name == "" {
c.Name = "Anonymous"
}
if c.Subject == "" {
c.Subject = "No Subject"
}
}
// Validate the form.
func (c Contact) Validate() error {
if len(c.Message) == 0 {
return errors.New("message is required")
}
return nil
}

View File

@ -17,6 +17,7 @@ import (
// Email configuration. // Email configuration.
type Email struct { type Email struct {
To string To string
ReplyTo string
Admin bool /* admin view of the email */ Admin bool /* admin view of the email */
Subject string Subject string
UnsubscribeURL string UnsubscribeURL string
@ -71,6 +72,9 @@ func (b *Blog) SendEmail(email Email) {
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", fmt.Sprintf("%s <%s>", s.Site.Title, s.Mail.Sender)) m.SetHeader("From", fmt.Sprintf("%s <%s>", s.Site.Title, s.Mail.Sender))
m.SetHeader("To", email.To) m.SetHeader("To", email.To)
if email.ReplyTo != "" {
m.SetHeader("Reply-To", email.ReplyTo)
}
m.SetHeader("Subject", email.Subject) m.SetHeader("Subject", email.Subject)
m.SetBody("text/plain", plaintext) m.SetBody("text/plain", plaintext)
m.AddAlternative("text/html", html.String()) m.AddAlternative("text/html", html.String())
@ -81,6 +85,8 @@ func (b *Blog) SendEmail(email Email) {
InsecureSkipVerify: true, InsecureSkipVerify: true,
} }
} }
log.Info("SendEmail: %s (%s) to %s", email.Subject, email.Template, email.To)
if err := d.DialAndSend(m); err != nil { if err := d.DialAndSend(m); err != nil {
log.Error("SendEmail: %s", err.Error()) log.Error("SendEmail: %s", err.Error())
} }

View File

@ -18,7 +18,7 @@ var (
reMarkdownTitle = regexp.MustCompile(`(?m:^#([^#\r\n]+)$)`) reMarkdownTitle = regexp.MustCompile(`(?m:^#([^#\r\n]+)$)`)
// Match fenced code blocks with languages defined. // Match fenced code blocks with languages defined.
reFencedCode = regexp.MustCompile("```" + `([a-z]*)\n([\s\S]*?)\n\s*` + "```") reFencedCode = regexp.MustCompile("```" + `([a-z]*)[\r\n]([\s\S]*?)[\r\n]\s*` + "```")
// Regexp to match fenced code blocks in rendered Markdown HTML. // Regexp to match fenced code blocks in rendered Markdown HTML.
// Tweak this if you change Markdown engines later. // Tweak this if you change Markdown engines later.
@ -66,7 +66,6 @@ func (b *Blog) RenderMarkdown(input string) string {
func (b *Blog) RenderTrustedMarkdown(input string) string { func (b *Blog) RenderTrustedMarkdown(input string) string {
// Find and hang on to fenced code blocks. // Find and hang on to fenced code blocks.
codeBlocks := []codeBlock{} codeBlocks := []codeBlock{}
log.Info("RE: %s", reFencedCode.String())
matches := reFencedCode.FindAllStringSubmatch(input, -1) matches := reFencedCode.FindAllStringSubmatch(input, -1)
for i, m := range matches { for i, m := range matches {
language, source := m[1], m[2] language, source := m[1], m[2]

View File

@ -1,6 +1,9 @@
package posts package posts
import "strings" import (
"sort"
"strings"
)
// UpdateIndex updates a post's metadata in the blog index. // UpdateIndex updates a post's metadata in the blog index.
func UpdateIndex(p *Post) error { func UpdateIndex(p *Post) error {
@ -68,6 +71,59 @@ func (idx *Index) Delete(p *Post) error {
return DB.Commit("blog/index", idx) return DB.Commit("blog/index", idx)
} }
// Tag is a response from Tags including metadata about it.
type Tag struct {
Name string
Count int
}
type ByPopularity []Tag
func (s ByPopularity) Len() int {
return len(s)
}
func (s ByPopularity) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s ByPopularity) Less(i, j int) bool {
if s[i].Count < s[j].Count {
return true
} else if s[i].Count > s[j].Count {
return false
}
return s[i].Name < s[j].Name
}
// Tags returns the tags sorted by most frequent.
func (idx *Index) Tags() ([]Tag, error) {
idx, err := GetIndex()
if err != nil {
return nil, err
}
unique := map[string]*Tag{}
for _, post := range idx.Posts {
for _, name := range post.Tags {
tag, ok := unique[name]
if !ok {
tag = &Tag{name, 0}
unique[name] = tag
}
tag.Count++
}
}
// Sort the tags.
tags := []Tag{}
for _, tag := range unique {
tags = append(tags, *tag)
}
sort.Sort(sort.Reverse(ByPopularity(tags)))
return tags, nil
}
// CleanupFragments to clean up old URL fragments. // CleanupFragments to clean up old URL fragments.
func CleanupFragments() error { func CleanupFragments() error {
idx, err := GetIndex() idx, err := GetIndex()

View File

@ -13,7 +13,7 @@ import (
// PageHandler is the catch-all route handler, for serving static web pages. // PageHandler is the catch-all route handler, for serving static web pages.
func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
log.Debug("Catch-all page handler invoked for request URI: %s", path) // log.Debug("Catch-all page handler invoked for request URI: %s", path)
// Remove trailing slashes by redirecting them away. // Remove trailing slashes by redirecting them away.
if len(path) > 1 && path[len(path)-1] == '/' { if len(path) > 1 && path[len(path)-1] == '/' {
@ -39,7 +39,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
} }
// Is it a template file? // Is it a template file?
if strings.HasSuffix(filepath.URI, ".gohtml") || strings.HasSuffix(filepath.URI, ".html") { if strings.HasSuffix(filepath.URI, ".gohtml") {
b.RenderTemplate(w, r, filepath.URI, nil) b.RenderTemplate(w, r, filepath.URI, nil)
return return
} }

View File

@ -37,7 +37,6 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin
message = []string{"The page you were looking for was not found."} message = []string{"The page you were looking for was not found."}
} }
log.Error("HERE 2")
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
err := b.RenderTemplate(w, r, ".errors/404", &Vars{ err := b.RenderTemplate(w, r, ".errors/404", &Vars{
Message: message[0], Message: message[0],

View File

@ -121,6 +121,7 @@ func (b *Blog) RenderPartialTemplate(w io.Writer, path string, v interface{}, wi
"Now": time.Now, "Now": time.Now,
"RenderIndex": b.RenderIndex, "RenderIndex": b.RenderIndex,
"RenderPost": b.RenderPost, "RenderPost": b.RenderPost,
"RenderTags": b.RenderTags,
} }
if functions != nil { if functions != nil {
for name, fn := range functions { for name, fn := range functions {
@ -185,7 +186,6 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin
return b.RenderComments(session, csrf, r.URL.Path, subject, ids...) return b.RenderComments(session, csrf, r.URL.Path, subject, ids...)
}, },
}) })
log.Debug("Parsed template")
return nil return nil
} }

View File

@ -0,0 +1,48 @@
<!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">
{{ or .Data.Name "Anonymous" }} has left you a message:
<br><br>
{{ .Data.Message }}
<hr>
{{ if .Data.Email }}
You can e-mail them back at <a href="mailto:{{ .Data.Email }}">{{ .Data.Email }}</a>
{{ end }}
</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

@ -35,6 +35,9 @@
<li class="nav-item"> <li class="nav-item">
<a href="/archive" class="nav-link">Archive</a> <a href="/archive" class="nav-link">Archive</a>
</li> </li>
<li class="nav-item">
<a href="/contact" class="nav-link">Contact Me</a>
</li>
</ul> </ul>
<form class="form-inline mt-2 mt-md-0"> <form class="form-inline mt-2 mt-md-0">

7
root/blog/tags.gohtml Normal file
View File

@ -0,0 +1,7 @@
{{ define "title" }}Tags{{ end }}
{{ define "content" }}
<h1>Tags</h1>
{{ RenderTags .Request true }}
{{ end }}

View File

@ -0,0 +1,17 @@
{{ if .IndexView }}
Sorted by most frequently used:
<ul>
{{ range .Tags }}
<li><a href="/tagged/{{ .Name }}">{{ .Name }}</a> ({{ .Count }})</li>
{{ end }}
</ul>
{{ else }}
<ul>
{{ range $i, $t := .Tags }}
{{ if le $i 20 }}
<li><a href="/tagged/{{ .Name }}">{{ .Name }}</a> ({{ .Count }})</li>
{{ end }}
{{ end }}
</ul>
{{ end }}

View File

@ -2,13 +2,13 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-2"> <div class="markdown col-12 col-lg-1 mb-1">
<img src="{{ .Avatar }}" <img src="{{ .Avatar }}"
width="96" width="96"
height="96" height="96"
alt="Avatar image"> alt="Avatar image">
</div> </div>
<div class="col-10"> <div class="markdown col-12 col-lg-11">
<div class="comment-meta"> <div class="comment-meta">
{{ if and .UserID .Username }} {{ if and .UserID .Username }}
<a href="/u/{{ .Username }}"><strong>{{ or .Name "Anonymous" }}</strong></a> <a href="/u/{{ .Username }}"><strong>{{ or .Name "Anonymous" }}</strong></a>

64
root/contact.gohtml Normal file
View File

@ -0,0 +1,64 @@
{{ define "title" }}Contact Me{{ end }}
{{ define "content" }}
<h1>Contact Me</h1>
<p>
You can use the form below to send an e-mail to this website's
administrator.
</p>
<form method="POST" action="/contact">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<div class="form-group">
<label for="name">
Your name:
<small class="form-text text-muted">(so I know who you are)</small>
</label>
<input type="text"
name="name"
class="form-control"
id="name"
placeholder="Anonymous"
value="{{ .Form.Name }}">
</div>
<div class="form-group">
<label for="email">Your email:</label>
<input type="email"
name="email"
class="form-control"
id="email"
placeholder="(if you want a response)"
value="{{ .Form.Email }}">
</div>
<div class="form-group">
<label for="subject">
Message subject:
<small class="form-text text-muted">(optional)</small>
</label>
<input type="text"
name="subject"
class="form-control"
id="subject"
placeholder="No Subject"
value="{{ .Form.Subject }}">
</div>
<div class="form-group">
<label for="message">Message:</label>
<textarea class="form-control"
cols="40"
rows="12"
name="message"
id="message"
placeholder="Message"
required>{{ .Form.Message }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Send Message</button>
<div style="display: none">
If you can see these boxes, don't touch them.<br>
<input type="text" size="40" name="contact" value=""><br>
<input type="text" size="40" name="website" value="http://">
</div>
</form>
{{ end }}

View File

@ -29,9 +29,3 @@ a.blog-title {
font-size: smaller; font-size: smaller;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Code blocks treated as <pre> tags */
.markdown code {
display: block;
white-space: pre-line;
}