From 94cdc916acd7e14686050541ad9b008f530496ee Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 22 Dec 2017 18:34:58 -0800 Subject: [PATCH] Blog tag pages and contact form --- cmd/blog/main.go | 5 +++ core/app.go | 1 + core/blog.go | 50 ++++++++++++++--------- core/contact.go | 57 ++++++++++++++++++++++++++ core/forms/contact.go | 42 ++++++++++++++++++++ core/mail.go | 6 +++ core/markdown.go | 3 +- core/models/posts/index.go | 58 ++++++++++++++++++++++++++- core/pages.go | 4 +- core/responses.go | 1 - core/templates.go | 2 +- root/.email/contact.gohtml | 48 ++++++++++++++++++++++ root/.layout.gohtml | 3 ++ root/blog/tags.gohtml | 7 ++++ root/blog/tags.partial.gohtml | 17 ++++++++ root/comments/entry.partial.gohtml | 4 +- root/contact.gohtml | 64 ++++++++++++++++++++++++++++++ root/css/blog-core.css | 6 --- 18 files changed, 345 insertions(+), 33 deletions(-) create mode 100644 core/contact.go create mode 100644 core/forms/contact.go create mode 100644 root/.email/contact.gohtml create mode 100644 root/blog/tags.gohtml create mode 100644 root/blog/tags.partial.gohtml create mode 100644 root/contact.gohtml diff --git a/cmd/blog/main.go b/cmd/blog/main.go index eabbb49..64aa718 100644 --- a/cmd/blog/main.go +++ b/cmd/blog/main.go @@ -44,7 +44,12 @@ func main() { app := core.New(DocumentRoot, userRoot) if fDebug { app.Debug = true + } + + // Set $JSONDB_DEBUG=1 to debug JsonDB; it's very noisy! + if os.Getenv("JSONDB_DEBUG") != "" { jsondb.SetDebug(true) } + app.ListenAndServe(fAddress) } diff --git a/core/app.go b/core/app.go index 9718933..757706d 100644 --- a/core/app.go +++ b/core/app.go @@ -62,6 +62,7 @@ func New(documentRoot, userRoot string) *Blog { r.HandleFunc("/login", blog.LoginHandler) r.HandleFunc("/logout", blog.LogoutHandler) blog.AdminRoutes(r) + blog.ContactRoutes(r) blog.BlogRoutes(r) blog.CommentRoutes(r) diff --git a/core/blog.go b/core/blog.go index a747705..4f3a8a9 100644 --- a/core/blog.go +++ b/core/blog.go @@ -40,6 +40,7 @@ func (b *Blog) BlogRoutes(r *mux.Router) { // Public routes r.HandleFunc("/blog", b.IndexHandler) r.HandleFunc("/archive", b.BlogArchive) + r.HandleFunc("/tagged", b.Tagged) r.HandleFunc("/tagged/{tag}", b.Tagged) // Login-required routers. @@ -75,7 +76,9 @@ func (b *Blog) Tagged(w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) tag, ok := params["tag"] 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, "") @@ -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) continue } - var rendered template.HTML - - // Body has a snipped section? - if strings.Contains(post.Body, "") { - parts := strings.SplitN(post.Body, "", 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. 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{ Post: post, - Rendered: rendered, Author: author, NumComments: numComments, }) @@ -247,6 +235,31 @@ func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML { 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. func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) { idx, err := posts.GetIndex() @@ -266,7 +279,7 @@ func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) { continue } - label := post.Created.Format("2006-02") + label := post.Created.Format("2006-01") if _, ok := byMonth[label]; !ok { months = append(months, label) byMonth[label] = &Archive{ @@ -335,13 +348,14 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool, numComments int) templa var snipped bool if indexView { if strings.Contains(p.Body, "") { - log.Warn("HAS SNIP TAG!") parts := strings.SplitN(p.Body, "", 2) p.Body = parts[0] snipped = true } } + p.Body = strings.Replace(p.Body, "", "
", 1) + // Render the post to HTML. var rendered template.HTML if p.ContentType == string(MARKDOWN) { diff --git a/core/contact.go b/core/contact.go new file mode 100644 index 0000000..4c1dcbe --- /dev/null +++ b/core/contact.go @@ -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) + }) +} diff --git a/core/forms/contact.go b/core/forms/contact.go new file mode 100644 index 0000000..2cebbed --- /dev/null +++ b/core/forms/contact.go @@ -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 +} diff --git a/core/mail.go b/core/mail.go index ac75ea2..7245fac 100644 --- a/core/mail.go +++ b/core/mail.go @@ -17,6 +17,7 @@ import ( // Email configuration. type Email struct { To string + ReplyTo string Admin bool /* admin view of the email */ Subject string UnsubscribeURL string @@ -71,6 +72,9 @@ func (b *Blog) SendEmail(email Email) { m := gomail.NewMessage() m.SetHeader("From", fmt.Sprintf("%s <%s>", s.Site.Title, s.Mail.Sender)) m.SetHeader("To", email.To) + if email.ReplyTo != "" { + m.SetHeader("Reply-To", email.ReplyTo) + } m.SetHeader("Subject", email.Subject) m.SetBody("text/plain", plaintext) m.AddAlternative("text/html", html.String()) @@ -81,6 +85,8 @@ func (b *Blog) SendEmail(email Email) { InsecureSkipVerify: true, } } + + log.Info("SendEmail: %s (%s) to %s", email.Subject, email.Template, email.To) if err := d.DialAndSend(m); err != nil { log.Error("SendEmail: %s", err.Error()) } diff --git a/core/markdown.go b/core/markdown.go index a54a39f..e064475 100644 --- a/core/markdown.go +++ b/core/markdown.go @@ -18,7 +18,7 @@ var ( reMarkdownTitle = regexp.MustCompile(`(?m:^#([^#\r\n]+)$)`) // 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. // 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 { // Find and hang on to fenced code blocks. codeBlocks := []codeBlock{} - log.Info("RE: %s", reFencedCode.String()) matches := reFencedCode.FindAllStringSubmatch(input, -1) for i, m := range matches { language, source := m[1], m[2] diff --git a/core/models/posts/index.go b/core/models/posts/index.go index 9e0423f..0ee50f0 100644 --- a/core/models/posts/index.go +++ b/core/models/posts/index.go @@ -1,6 +1,9 @@ package posts -import "strings" +import ( + "sort" + "strings" +) // UpdateIndex updates a post's metadata in the blog index. func UpdateIndex(p *Post) error { @@ -68,6 +71,59 @@ func (idx *Index) Delete(p *Post) error { 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. func CleanupFragments() error { idx, err := GetIndex() diff --git a/core/pages.go b/core/pages.go index 7ce6de0..30fb261 100644 --- a/core/pages.go +++ b/core/pages.go @@ -13,7 +13,7 @@ import ( // PageHandler is the catch-all route handler, for serving static web pages. func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { 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. 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? - if strings.HasSuffix(filepath.URI, ".gohtml") || strings.HasSuffix(filepath.URI, ".html") { + if strings.HasSuffix(filepath.URI, ".gohtml") { b.RenderTemplate(w, r, filepath.URI, nil) return } diff --git a/core/responses.go b/core/responses.go index 11126b3..c654c12 100644 --- a/core/responses.go +++ b/core/responses.go @@ -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."} } - log.Error("HERE 2") w.WriteHeader(http.StatusNotFound) err := b.RenderTemplate(w, r, ".errors/404", &Vars{ Message: message[0], diff --git a/core/templates.go b/core/templates.go index e3a51ad..a3a5017 100644 --- a/core/templates.go +++ b/core/templates.go @@ -121,6 +121,7 @@ func (b *Blog) RenderPartialTemplate(w io.Writer, path string, v interface{}, wi "Now": time.Now, "RenderIndex": b.RenderIndex, "RenderPost": b.RenderPost, + "RenderTags": b.RenderTags, } if functions != nil { 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...) }, }) - log.Debug("Parsed template") return nil } diff --git a/root/.email/contact.gohtml b/root/.email/contact.gohtml new file mode 100644 index 0000000..a702cb9 --- /dev/null +++ b/root/.email/contact.gohtml @@ -0,0 +1,48 @@ + + + + + + + + {{ .Subject }} + + + +
+ + + + + + + + + + +
+ + {{ .Subject }} + +
+ + {{ or .Data.Name "Anonymous" }} has left you a message: +

+ + {{ .Data.Message }} + +
+ + {{ if .Data.Email }} + You can e-mail them back at {{ .Data.Email }} + {{ end }} +
+
+ + This e-mail was automatically generated; do not reply to it. + +
+
+ + + diff --git a/root/.layout.gohtml b/root/.layout.gohtml index d4701eb..0c09f22 100644 --- a/root/.layout.gohtml +++ b/root/.layout.gohtml @@ -35,6 +35,9 @@ +
diff --git a/root/blog/tags.gohtml b/root/blog/tags.gohtml new file mode 100644 index 0000000..6ef4b1b --- /dev/null +++ b/root/blog/tags.gohtml @@ -0,0 +1,7 @@ +{{ define "title" }}Tags{{ end }} +{{ define "content" }} +

Tags

+ +{{ RenderTags .Request true }} + +{{ end }} diff --git a/root/blog/tags.partial.gohtml b/root/blog/tags.partial.gohtml new file mode 100644 index 0000000..ba0c8fc --- /dev/null +++ b/root/blog/tags.partial.gohtml @@ -0,0 +1,17 @@ +{{ if .IndexView }} + Sorted by most frequently used: + +
    + {{ range .Tags }} +
  • {{ .Name }} ({{ .Count }})
  • + {{ end }} +
+{{ else }} +
    + {{ range $i, $t := .Tags }} + {{ if le $i 20 }} +
  • {{ .Name }} ({{ .Count }})
  • + {{ end }} + {{ end }} +
+{{ end }} diff --git a/root/comments/entry.partial.gohtml b/root/comments/entry.partial.gohtml index f41b09e..5b69177 100644 --- a/root/comments/entry.partial.gohtml +++ b/root/comments/entry.partial.gohtml @@ -2,13 +2,13 @@
-
+
Avatar image
-
+
{{ if and .UserID .Username }} {{ or .Name "Anonymous" }} diff --git a/root/contact.gohtml b/root/contact.gohtml new file mode 100644 index 0000000..a1af15b --- /dev/null +++ b/root/contact.gohtml @@ -0,0 +1,64 @@ +{{ define "title" }}Contact Me{{ end }} +{{ define "content" }} +

Contact Me

+ +

+ You can use the form below to send an e-mail to this website's + administrator. +

+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ If you can see these boxes, don't touch them.
+
+ +
+ +{{ end }} diff --git a/root/css/blog-core.css b/root/css/blog-core.css index 8ca42bd..4acb0f5 100644 --- a/root/css/blog-core.css +++ b/root/css/blog-core.css @@ -29,9 +29,3 @@ a.blog-title { font-size: smaller; margin-bottom: 1rem; } - -/* Code blocks treated as
 tags */
-.markdown code {
-    display: block;
-    white-space: pre-line;
-}