From 3d5147ff4cd79859961df5342489e103edc8f29c Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 9 Feb 2018 18:46:58 -0800 Subject: [PATCH 01/10] Shuffle around package directories --- cmd/blog/main.go | 2 +- cmd/rophako-import/main.go | 2 +- core/admin.go | 8 ++++---- core/app.go | 16 ++++++++-------- core/auth.go | 4 ++-- core/blog.go | 8 ++++---- core/comments.go | 4 ++-- core/contact.go | 4 ++-- core/initial-setup.go | 6 +++--- core/{ => internal}/forms/auth.go | 0 core/{ => internal}/forms/contact.go | 0 core/{ => internal}/forms/forms.go | 0 core/{ => internal}/forms/settings.go | 0 core/{ => internal}/forms/setup.go | 0 core/{ => internal}/models/comments/comments.go | 2 +- .../models/comments/subscribers.go | 0 core/{ => internal}/models/posts/index.go | 0 core/{ => internal}/models/posts/posts.go | 2 +- core/{ => internal}/models/posts/sorting.go | 0 core/{ => internal}/models/settings/settings.go | 2 +- core/{ => internal}/models/users/users.go | 2 +- core/{ => internal}/models/users/utils.go | 0 core/mail.go | 4 ++-- core/middleware.go | 2 +- core/templates.go | 6 +++--- {core/jsondb => jsondb}/cache.go | 0 {core => jsondb}/caches/caches.go | 0 {core => jsondb}/caches/null/null.go | 0 {core => jsondb}/caches/redis/redis.go | 0 {core/jsondb => jsondb}/jsondb.go | 2 +- {core/jsondb => jsondb}/log.go | 0 31 files changed, 38 insertions(+), 38 deletions(-) rename core/{ => internal}/forms/auth.go (100%) rename core/{ => internal}/forms/contact.go (100%) rename core/{ => internal}/forms/forms.go (100%) rename core/{ => internal}/forms/settings.go (100%) rename core/{ => internal}/forms/setup.go (100%) rename core/{ => internal}/models/comments/comments.go (99%) rename core/{ => internal}/models/comments/subscribers.go (100%) rename core/{ => internal}/models/posts/index.go (100%) rename core/{ => internal}/models/posts/posts.go (99%) rename core/{ => internal}/models/posts/sorting.go (100%) rename core/{ => internal}/models/settings/settings.go (98%) rename core/{ => internal}/models/users/users.go (99%) rename core/{ => internal}/models/users/utils.go (100%) rename {core/jsondb => jsondb}/cache.go (100%) rename {core => jsondb}/caches/caches.go (100%) rename {core => jsondb}/caches/null/null.go (100%) rename {core => jsondb}/caches/redis/redis.go (100%) rename {core/jsondb => jsondb}/jsondb.go (99%) rename {core/jsondb => jsondb}/log.go (100%) diff --git a/cmd/blog/main.go b/cmd/blog/main.go index 64aa718..1937f9e 100644 --- a/cmd/blog/main.go +++ b/cmd/blog/main.go @@ -10,7 +10,7 @@ import ( "os" "github.com/kirsle/blog/core" - "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/blog/jsondb" ) // Build-time config constants. diff --git a/cmd/rophako-import/main.go b/cmd/rophako-import/main.go index 78061f6..650832e 100644 --- a/cmd/rophako-import/main.go +++ b/cmd/rophako-import/main.go @@ -16,7 +16,7 @@ import ( "time" "github.com/google/uuid" - "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/blog/jsondb" "github.com/kirsle/blog/core/models/comments" "github.com/kirsle/blog/core/models/posts" "github.com/kirsle/golog" diff --git a/core/admin.go b/core/admin.go index feda5c9..ad0b1b1 100644 --- a/core/admin.go +++ b/core/admin.go @@ -11,10 +11,10 @@ import ( "strings" "github.com/gorilla/mux" - "github.com/kirsle/blog/core/caches/null" - "github.com/kirsle/blog/core/caches/redis" - "github.com/kirsle/blog/core/forms" - "github.com/kirsle/blog/core/models/settings" + "github.com/kirsle/blog/jsondb/caches/null" + "github.com/kirsle/blog/jsondb/caches/redis" + "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/models/settings" "github.com/urfave/negroni" ) diff --git a/core/app.go b/core/app.go index f8f7386..68182e2 100644 --- a/core/app.go +++ b/core/app.go @@ -7,14 +7,14 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/sessions" - "github.com/kirsle/blog/core/caches" - "github.com/kirsle/blog/core/caches/null" - "github.com/kirsle/blog/core/caches/redis" - "github.com/kirsle/blog/core/jsondb" - "github.com/kirsle/blog/core/models/comments" - "github.com/kirsle/blog/core/models/posts" - "github.com/kirsle/blog/core/models/settings" - "github.com/kirsle/blog/core/models/users" + "github.com/kirsle/blog/jsondb/caches" + "github.com/kirsle/blog/jsondb/caches/null" + "github.com/kirsle/blog/jsondb/caches/redis" + "github.com/kirsle/blog/jsondb" + "github.com/kirsle/blog/core/internal/models/comments" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/models/users" "github.com/shurcooL/github_flavored_markdown/gfmstyle" "github.com/urfave/negroni" ) diff --git a/core/auth.go b/core/auth.go index 4ac67e1..e0babce 100644 --- a/core/auth.go +++ b/core/auth.go @@ -5,8 +5,8 @@ import ( "net/http" "github.com/gorilla/mux" - "github.com/kirsle/blog/core/forms" - "github.com/kirsle/blog/core/models/users" + "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/models/users" ) // AuthRoutes attaches the auth routes to the app. diff --git a/core/blog.go b/core/blog.go index 94f2400..d3d8d17 100644 --- a/core/blog.go +++ b/core/blog.go @@ -13,10 +13,10 @@ import ( "github.com/gorilla/feeds" "github.com/gorilla/mux" - "github.com/kirsle/blog/core/models/comments" - "github.com/kirsle/blog/core/models/posts" - "github.com/kirsle/blog/core/models/settings" - "github.com/kirsle/blog/core/models/users" + "github.com/kirsle/blog/core/internal/models/comments" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/models/users" "github.com/urfave/negroni" ) diff --git a/core/comments.go b/core/comments.go index 710c87d..bfbae2e 100644 --- a/core/comments.go +++ b/core/comments.go @@ -11,8 +11,8 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" "github.com/gorilla/sessions" - "github.com/kirsle/blog/core/models/comments" - "github.com/kirsle/blog/core/models/users" + "github.com/kirsle/blog/core/internal/models/comments" + "github.com/kirsle/blog/core/internal/models/users" ) // CommentRoutes attaches the comment routes to the app. diff --git a/core/contact.go b/core/contact.go index f8b6f8a..f1716f3 100644 --- a/core/contact.go +++ b/core/contact.go @@ -9,8 +9,8 @@ import ( "time" "github.com/gorilla/mux" - "github.com/kirsle/blog/core/forms" - "github.com/kirsle/blog/core/models/settings" + "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/models/settings" ) // ContactRoutes attaches the contact URL to the app. diff --git a/core/initial-setup.go b/core/initial-setup.go index ee88843..43afda5 100644 --- a/core/initial-setup.go +++ b/core/initial-setup.go @@ -4,9 +4,9 @@ import ( "net/http" "github.com/gorilla/sessions" - "github.com/kirsle/blog/core/forms" - "github.com/kirsle/blog/core/models/settings" - "github.com/kirsle/blog/core/models/users" + "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/models/users" ) // SetupHandler is the initial blog setup route. diff --git a/core/forms/auth.go b/core/internal/forms/auth.go similarity index 100% rename from core/forms/auth.go rename to core/internal/forms/auth.go diff --git a/core/forms/contact.go b/core/internal/forms/contact.go similarity index 100% rename from core/forms/contact.go rename to core/internal/forms/contact.go diff --git a/core/forms/forms.go b/core/internal/forms/forms.go similarity index 100% rename from core/forms/forms.go rename to core/internal/forms/forms.go diff --git a/core/forms/settings.go b/core/internal/forms/settings.go similarity index 100% rename from core/forms/settings.go rename to core/internal/forms/settings.go diff --git a/core/forms/setup.go b/core/internal/forms/setup.go similarity index 100% rename from core/forms/setup.go rename to core/internal/forms/setup.go diff --git a/core/models/comments/comments.go b/core/internal/models/comments/comments.go similarity index 99% rename from core/models/comments/comments.go rename to core/internal/models/comments/comments.go index 816b5ef..7e3345b 100644 --- a/core/models/comments/comments.go +++ b/core/internal/models/comments/comments.go @@ -11,7 +11,7 @@ import ( "time" "github.com/google/uuid" - "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/blog/jsondb" "github.com/kirsle/golog" ) diff --git a/core/models/comments/subscribers.go b/core/internal/models/comments/subscribers.go similarity index 100% rename from core/models/comments/subscribers.go rename to core/internal/models/comments/subscribers.go diff --git a/core/models/posts/index.go b/core/internal/models/posts/index.go similarity index 100% rename from core/models/posts/index.go rename to core/internal/models/posts/index.go diff --git a/core/models/posts/posts.go b/core/internal/models/posts/posts.go similarity index 99% rename from core/models/posts/posts.go rename to core/internal/models/posts/posts.go index f25d80f..2ce884e 100644 --- a/core/models/posts/posts.go +++ b/core/internal/models/posts/posts.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/blog/jsondb" "github.com/kirsle/golog" ) diff --git a/core/models/posts/sorting.go b/core/internal/models/posts/sorting.go similarity index 100% rename from core/models/posts/sorting.go rename to core/internal/models/posts/sorting.go diff --git a/core/models/settings/settings.go b/core/internal/models/settings/settings.go similarity index 98% rename from core/models/settings/settings.go rename to core/internal/models/settings/settings.go index d933ffd..9407c3f 100644 --- a/core/models/settings/settings.go +++ b/core/internal/models/settings/settings.go @@ -4,7 +4,7 @@ import ( "crypto/rand" "encoding/base64" - "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/blog/jsondb" ) // DB is a reference to the parent app's JsonDB object. diff --git a/core/models/users/users.go b/core/internal/models/users/users.go similarity index 99% rename from core/models/users/users.go rename to core/internal/models/users/users.go index ab74307..e125704 100644 --- a/core/models/users/users.go +++ b/core/internal/models/users/users.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/blog/jsondb" "golang.org/x/crypto/bcrypt" ) diff --git a/core/models/users/utils.go b/core/internal/models/users/utils.go similarity index 100% rename from core/models/users/utils.go rename to core/internal/models/users/utils.go diff --git a/core/mail.go b/core/mail.go index 7245fac..4ff5c2b 100644 --- a/core/mail.go +++ b/core/mail.go @@ -8,8 +8,8 @@ import ( "net/url" "strings" - "github.com/kirsle/blog/core/models/comments" - "github.com/kirsle/blog/core/models/settings" + "github.com/kirsle/blog/core/internal/models/comments" + "github.com/kirsle/blog/core/internal/models/settings" "github.com/microcosm-cc/bluemonday" gomail "gopkg.in/gomail.v2" ) diff --git a/core/middleware.go b/core/middleware.go index 0fc8d8f..db552c6 100644 --- a/core/middleware.go +++ b/core/middleware.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/sessions" - "github.com/kirsle/blog/core/models/users" + "github.com/kirsle/blog/core/internal/models/users" ) type key int diff --git a/core/templates.go b/core/templates.go index ff19298..e79c6e5 100644 --- a/core/templates.go +++ b/core/templates.go @@ -7,9 +7,9 @@ import ( "strings" "time" - "github.com/kirsle/blog/core/forms" - "github.com/kirsle/blog/core/models/settings" - "github.com/kirsle/blog/core/models/users" + "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/models/users" ) // Vars is an interface to implement by the templates to pass their own custom diff --git a/core/jsondb/cache.go b/jsondb/cache.go similarity index 100% rename from core/jsondb/cache.go rename to jsondb/cache.go diff --git a/core/caches/caches.go b/jsondb/caches/caches.go similarity index 100% rename from core/caches/caches.go rename to jsondb/caches/caches.go diff --git a/core/caches/null/null.go b/jsondb/caches/null/null.go similarity index 100% rename from core/caches/null/null.go rename to jsondb/caches/null/null.go diff --git a/core/caches/redis/redis.go b/jsondb/caches/redis/redis.go similarity index 100% rename from core/caches/redis/redis.go rename to jsondb/caches/redis/redis.go diff --git a/core/jsondb/jsondb.go b/jsondb/jsondb.go similarity index 99% rename from core/jsondb/jsondb.go rename to jsondb/jsondb.go index d74a435..d5712d4 100644 --- a/core/jsondb/jsondb.go +++ b/jsondb/jsondb.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "github.com/kirsle/blog/core/caches" + "github.com/kirsle/blog/jsondb/caches" ) var ( diff --git a/core/jsondb/log.go b/jsondb/log.go similarity index 100% rename from core/jsondb/log.go rename to jsondb/log.go From aabcf5918129871b5d2430e9ea9f5e961f00323a Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 9 Feb 2018 19:01:56 -0800 Subject: [PATCH 02/10] Branch markdown into its own subpackage --- core/blog.go | 5 +-- core/comments.go | 5 +-- core/contact.go | 3 +- core/{app.go => core.go} | 11 ++++--- core/{ => internal/markdown}/markdown.go | 42 ++++++++++++++++-------- core/mail.go | 3 +- core/pages.go | 6 ++-- core/regexp.go | 8 ----- 8 files changed, 49 insertions(+), 34 deletions(-) rename core/{app.go => core.go} (95%) rename core/{ => internal/markdown}/markdown.go (78%) delete mode 100644 core/regexp.go diff --git a/core/blog.go b/core/blog.go index d3d8d17..b1b73f6 100644 --- a/core/blog.go +++ b/core/blog.go @@ -13,6 +13,7 @@ import ( "github.com/gorilla/feeds" "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/posts" "github.com/kirsle/blog/core/internal/models/settings" @@ -439,7 +440,7 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool, numComments int) templa // Render the post to HTML. var rendered template.HTML if p.ContentType == string(MARKDOWN) { - rendered = template.HTML(b.RenderTrustedMarkdown(p.Body)) + rendered = template.HTML(markdown.RenderTrustedMarkdown(p.Body)) } else { rendered = template.HTML(p.Body) } @@ -490,7 +491,7 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { switch r.FormValue("submit") { case "preview": if post.ContentType == string(MARKDOWN) { - v.Data["preview"] = template.HTML(b.RenderTrustedMarkdown(post.Body)) + v.Data["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) } else { v.Data["preview"] = template.HTML(post.Body) } diff --git a/core/comments.go b/core/comments.go index bfbae2e..521521d 100644 --- a/core/comments.go +++ b/core/comments.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" "github.com/gorilla/sessions" + "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/users" ) @@ -62,7 +63,7 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject // Render all the comments in the thread. userMap := map[int]*users.User{} for _, c := range thread.Comments { - c.HTML = template.HTML(b.RenderMarkdown(c.Body)) + c.HTML = template.HTML(markdown.RenderMarkdown(c.Body)) c.ThreadID = thread.ID c.OriginURL = url c.CSRF = csrfToken @@ -203,7 +204,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { c.Email = currentUser.Email c.LoadAvatar() } - c.HTML = template.HTML(b.RenderMarkdown(c.Body)) + c.HTML = template.HTML(markdown.RenderMarkdown(c.Body)) case "post": if err := c.Validate(); err != nil { v.Error = err diff --git a/core/contact.go b/core/contact.go index f1716f3..f4cb61e 100644 --- a/core/contact.go +++ b/core/contact.go @@ -10,6 +10,7 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/settings" ) @@ -47,7 +48,7 @@ func (b *Blog) ContactRoutes(r *mux.Router) { Template: ".email/contact.gohtml", Data: map[string]interface{}{ "Name": form.Name, - "Message": template.HTML(b.RenderMarkdown(form.Message)), + "Message": template.HTML(markdown.RenderMarkdown(form.Message)), "Email": form.Email, }, }) diff --git a/core/app.go b/core/core.go similarity index 95% rename from core/app.go rename to core/core.go index 68182e2..5a48ecb 100644 --- a/core/app.go +++ b/core/core.go @@ -1,3 +1,4 @@ +// Package core implements the core source code of kirsle/blog. package core import ( @@ -7,14 +8,15 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/sessions" - "github.com/kirsle/blog/jsondb/caches" - "github.com/kirsle/blog/jsondb/caches/null" - "github.com/kirsle/blog/jsondb/caches/redis" - "github.com/kirsle/blog/jsondb" + "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/posts" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/jsondb" + "github.com/kirsle/blog/jsondb/caches" + "github.com/kirsle/blog/jsondb/caches/null" + "github.com/kirsle/blog/jsondb/caches/redis" "github.com/shurcooL/github_flavored_markdown/gfmstyle" "github.com/urfave/negroni" ) @@ -76,6 +78,7 @@ func New(documentRoot, userRoot string) *Blog { } else { blog.Cache = cache blog.DB.Cache = cache + markdown.Cache = cache } } diff --git a/core/markdown.go b/core/internal/markdown/markdown.go similarity index 78% rename from core/markdown.go rename to core/internal/markdown/markdown.go index 28953ba..f48b748 100644 --- a/core/markdown.go +++ b/core/internal/markdown/markdown.go @@ -1,4 +1,5 @@ -package core +// Package markdown implements a GitHub Flavored Markdown renderer. +package markdown import ( "bytes" @@ -10,17 +11,23 @@ import ( "regexp" "strings" + "github.com/kirsle/blog/jsondb/caches" + "github.com/kirsle/golog" "github.com/microcosm-cc/bluemonday" "github.com/shurcooL/github_flavored_markdown" ) // Regexps for Markdown use cases. var ( + // Plug your own Redis cacher in. + Cache caches.Cacher + // Match title from the first `# h1` heading. reMarkdownTitle = regexp.MustCompile(`(?m:^#([^#\r\n]+)$)`) // Match fenced code blocks with languages defined. - reFencedCode = regexp.MustCompile("```" + `([a-z]*)[\r\n]([\s\S]*?)[\r\n]\s*` + "```") + reFencedCode = regexp.MustCompile("```" + `([a-z]*)[\r\n]([\s\S]*?)[\r\n]\s*` + "```") + reFencedCodeClass = regexp.MustCompile("^highlight highlight-[a-zA-Z0-9]+$") // Regexp to match fenced code blocks in rendered Markdown HTML. // Tweak this if you change Markdown engines later. @@ -28,6 +35,12 @@ var ( reDecodeBlock = regexp.MustCompile(`\[?FENCED_CODE_%d_BLOCK?\]`) ) +var log *golog.Logger + +func init() { + log = golog.GetLogger("blog") +} + // A container for parsed code blocks. type codeBlock struct { placeholder int @@ -51,8 +64,8 @@ func TitleFromMarkdown(body string) (string, error) { // RenderMarkdown renders markdown to HTML, safely. It uses blackfriday to // render Markdown to HTML and then Bluemonday to sanitize the resulting HTML. -func (b *Blog) RenderMarkdown(input string) string { - unsafe := []byte(b.RenderTrustedMarkdown(input)) +func RenderMarkdown(input string) string { + unsafe := []byte(RenderTrustedMarkdown(input)) // Sanitize HTML, but allow fenced code blocks to not get mangled in user // submitted comments. @@ -65,7 +78,7 @@ func (b *Blog) RenderMarkdown(input string) string { // RenderTrustedMarkdown renders markdown to HTML, but without applying // bluemonday filtering afterward. This is for blog posts and website // Markdown pages, not for user-submitted comments or things. -func (b *Blog) RenderTrustedMarkdown(input string) string { +func RenderTrustedMarkdown(input string) string { // Find and hang on to fenced code blocks. codeBlocks := []codeBlock{} matches := reFencedCode.FindAllStringSubmatch(input, -1) @@ -87,7 +100,7 @@ func (b *Blog) RenderTrustedMarkdown(input string) string { // Substitute fenced codes back in. for _, block := range codeBlocks { - highlighted, _ := b.Pygmentize(block.language, block.source) + highlighted, _ := Pygmentize(block.language, block.source) html = strings.Replace(html, fmt.Sprintf("[?FENCED_CODE_%d_BLOCK?]", block.placeholder), highlighted, @@ -105,7 +118,7 @@ func (b *Blog) RenderTrustedMarkdown(input string) string { // // The rendered result is cached in Redis if available, because the CLI // call takes ~0.6s which is slow if you're rendering a lot of code blocks. -func (b *Blog) Pygmentize(language, source string) (string, error) { +func Pygmentize(language, source string) (string, error) { var result string // Hash the source for the cache key. @@ -114,10 +127,11 @@ func (b *Blog) Pygmentize(language, source string) (string, error) { hash := fmt.Sprintf("%x", h.Sum(nil)) cacheKey := "pygmentize:" + hash + _ = cacheKey // Do we have it cached? - if cached, err := b.Cache.Get(cacheKey); err == nil && len(cached) > 0 { - return string(cached), nil - } + // if cached, err := b.Cache.Get(cacheKey); err == nil && len(cached) > 0 { + // return string(cached), nil + // } // Defer to the `pygmentize` command bin := "pygmentize" @@ -140,10 +154,10 @@ func (b *Blog) Pygmentize(language, source string) (string, error) { } result = out.String() - err := b.Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change - if err != nil { - log.Error("Couldn't cache Pygmentize output: %s", err) - } + // err := b.Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change + // if err != nil { + // log.Error("Couldn't cache Pygmentize output: %s", err) + // } return result, nil } diff --git a/core/mail.go b/core/mail.go index 4ff5c2b..6c0f750 100644 --- a/core/mail.go +++ b/core/mail.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" + "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/settings" "github.com/microcosm-cc/bluemonday" @@ -107,7 +108,7 @@ func (b *Blog) NotifyComment(c *comments.Comment) { Data: map[string]interface{}{ "Name": c.Name, "Subject": c.Subject, - "Body": template.HTML(b.RenderMarkdown(c.Body)), + "Body": template.HTML(markdown.RenderMarkdown(c.Body)), "URL": strings.Trim(s.Site.URL, "/") + c.OriginURL, "QuickDelete": fmt.Sprintf("%s/comments/quick-delete?t=%s&d=%s", strings.Trim(s.Site.URL, "/"), diff --git a/core/pages.go b/core/pages.go index 6712a57..6024ca0 100644 --- a/core/pages.go +++ b/core/pages.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/kirsle/blog/core/internal/markdown" ) // PageHandler is the catch-all route handler, for serving static web pages. @@ -54,8 +56,8 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Render it to HTML and find out its title. body := string(source) - html := b.RenderTrustedMarkdown(body) - title, _ := TitleFromMarkdown(body) + html := markdown.RenderTrustedMarkdown(body) + title, _ := markdown.TitleFromMarkdown(body) b.RenderTemplate(w, r, ".markdown", NewVars(map[interface{}]interface{}{ "Title": title, diff --git a/core/regexp.go b/core/regexp.go deleted file mode 100644 index 3a3ffbe..0000000 --- a/core/regexp.go +++ /dev/null @@ -1,8 +0,0 @@ -package core - -import "regexp" - -var ( - // CSS classes for Markdown fenced code blocks - reFencedCodeClass = regexp.MustCompile("^highlight highlight-[a-zA-Z0-9]+$") -) From e393b1880fa89c4a849013e402c87683a38c94e2 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 10 Feb 2018 10:08:45 -0800 Subject: [PATCH 03/10] Move template rendering into sub-package --- cmd/blog/main.go | 2 +- core/admin.go | 31 +- core/auth.go | 1 + core/blog.go | 60 ++- core/comments.go | 6 +- core/core.go | 64 ++- core/initial-setup.go | 4 +- core/internal/log/log.go | 30 ++ core/internal/markdown/markdown.go | 28 +- core/internal/render/resolve_paths.go | 92 ++++ core/internal/render/templates.go | 159 ++++++ core/internal/responses/responses.go | 66 +++ core/log.go | 13 - core/mail.go | 4 +- core/middleware.go | 5 + core/pages.go | 84 +-- core/responses.go | 11 +- core/templates.go | 133 ++--- root/blog/entry.gohtml | 2 +- root/blog/entry.partial.gohtml | 15 +- root/blog/index.partial.gohtml | 25 +- root/blog/tags.partial.gohtml | 10 +- root/bluez/theme.css | 12 + root/comments/entry.partial.gohtml | 2 +- root/index.gohtml | 1 + root/markdown.gohtml | 712 ++++++++++++++++++++++++++ 26 files changed, 1271 insertions(+), 301 deletions(-) create mode 100644 core/internal/log/log.go create mode 100644 core/internal/render/resolve_paths.go create mode 100644 core/internal/render/templates.go create mode 100644 core/internal/responses/responses.go delete mode 100644 core/log.go create mode 100644 root/markdown.gohtml diff --git a/cmd/blog/main.go b/cmd/blog/main.go index 1937f9e..f4de630 100644 --- a/cmd/blog/main.go +++ b/cmd/blog/main.go @@ -51,5 +51,5 @@ func main() { jsondb.SetDebug(true) } - app.ListenAndServe(fAddress) + app.Run(fAddress) } diff --git a/core/admin.go b/core/admin.go index ad0b1b1..8b142db 100644 --- a/core/admin.go +++ b/core/admin.go @@ -1,7 +1,6 @@ package core import ( - "fmt" "io/ioutil" "net/http" "net/url" @@ -11,10 +10,9 @@ import ( "strings" "github.com/gorilla/mux" - "github.com/kirsle/blog/jsondb/caches/null" - "github.com/kirsle/blog/jsondb/caches/redis" "github.com/kirsle/blog/core/internal/forms" "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/render" "github.com/urfave/negroni" ) @@ -33,13 +31,13 @@ func (b *Blog) AdminRoutes(r *mux.Router) { // AdminHandler is the admin landing page. func (b *Blog) AdminHandler(w http.ResponseWriter, r *http.Request) { - b.RenderTemplate(w, r, "admin/index", nil) + b.RenderTemplate(w, r, "admin/index", render.Vars{}) } // FileTree holds information about files in the document roots. type FileTree struct { UserRoot bool // false = CoreRoot - Files []Filepath + Files []render.Filepath } // EditorHandler lets you edit web pages from the frontend. @@ -127,7 +125,7 @@ func (b *Blog) editorFileList(w http.ResponseWriter, r *http.Request) { for i, root := range []string{b.UserRoot, b.DocumentRoot} { tree := FileTree{ UserRoot: i == 0, - Files: []Filepath{}, + Files: []render.Filepath{}, } filepath.Walk(root, func(path string, f os.FileInfo, err error) error { @@ -155,7 +153,7 @@ func (b *Blog) editorFileList(w http.ResponseWriter, r *http.Request) { return nil } - tree.Files = append(tree.Files, Filepath{ + tree.Files = append(tree.Files, render.Filepath{ Absolute: abs, Relative: rel, Basename: filepath.Base(path), @@ -224,24 +222,7 @@ func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) { } else { // Save the settings. settings.Save() - - // Reset Redis configuration. - if settings.Redis.Enabled { - cache, err := redis.New( - fmt.Sprintf("%s:%d", settings.Redis.Host, settings.Redis.Port), - settings.Redis.DB, - settings.Redis.Prefix, - ) - if err != nil { - b.Flash(w, r, "Error connecting to Redis: %s", err) - b.Cache = null.New() - } else { - b.Cache = cache - } - } else { - b.Cache = null.New() - } - b.DB.Cache = b.Cache + b.Configure() b.FlashAndReload(w, r, "Settings have been saved!") return diff --git a/core/auth.go b/core/auth.go index e0babce..d601480 100644 --- a/core/auth.go +++ b/core/auth.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/models/users" ) diff --git a/core/blog.go b/core/blog.go index b1b73f6..8028596 100644 --- a/core/blog.go +++ b/core/blog.go @@ -13,11 +13,13 @@ import ( "github.com/gorilla/feeds" "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/posts" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" "github.com/urfave/negroni" ) @@ -306,12 +308,20 @@ func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML { // Render the blog index partial. var output bytes.Buffer - v := map[string]interface{}{ - "PreviousPage": previousPage, - "NextPage": nextPage, - "View": view, + v := render.Vars{ + Data: map[interface{}]interface{}{ + "PreviousPage": previousPage, + "NextPage": nextPage, + "View": view, + }, } - b.RenderPartialTemplate(&output, "blog/index.partial", v, false, nil) + v = b.LoadDefaults(v, r) + render.PartialTemplate(&output, "blog/index.partial", render.Config{ + Request: r, + Vars: &v, + WithLayout: false, + Functions: b.TemplateFuncs(nil, r, nil), + }) return template.HTML(output.String()) } @@ -329,14 +339,20 @@ func (b *Blog) RenderTags(r *http.Request, indexView bool) template.HTML { } var output bytes.Buffer - v := struct { - IndexView bool - Tags []posts.Tag - }{ - IndexView: indexView, - Tags: tags, + v := render.Vars{ + Data: map[interface{}]interface{}{ + "IndexView": indexView, + "Tags": tags, + }, } - b.RenderPartialTemplate(&output, "blog/tags.partial", v, false, nil) + v = b.LoadDefaults(v, r) + render.PartialTemplate(&output, "blog/tags.partial", render.Config{ + Request: r, + Vars: &v, + WithLayout: false, + Functions: b.TemplateFuncs(nil, nil, nil), + }) + // b.RenderPartialTemplate(&output, r, "blog/tags.partial", v, false, nil) return template.HTML(output.String()) } @@ -417,7 +433,7 @@ func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string) // RenderPost renders a blog post as a partial template and returns the HTML. // If indexView is true, the blog headers will be hyperlinked to the dedicated // entry view page. -func (b *Blog) RenderPost(p *posts.Post, indexView bool, numComments int) template.HTML { +func (b *Blog) RenderPost(r *http.Request, p *posts.Post, indexView bool, numComments int) template.HTML { // Look up the author's information. author, err := users.LoadReadonly(p.AuthorID) if err != nil { @@ -445,16 +461,18 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool, numComments int) templa rendered = template.HTML(p.Body) } - meta := PostMeta{ - Post: p, - Rendered: rendered, - Author: author, - IndexView: indexView, - Snipped: snipped, - NumComments: numComments, + meta := render.Vars{ + Data: map[interface{}]interface{}{ + "Post": p, + "Rendered": rendered, + "Author": author, + "IndexView": indexView, + "Snipped": snipped, + "NumComments": numComments, + }, } output := bytes.Buffer{} - err = b.RenderPartialTemplate(&output, "blog/entry.partial", meta, false, nil) + err = b.RenderPartialTemplate(&output, r, "blog/entry.partial", meta, false, nil) if err != nil { return template.HTML(fmt.Sprintf("[template error in blog/entry.partial: %s]", err.Error())) } diff --git a/core/comments.go b/core/comments.go index 521521d..40066e8 100644 --- a/core/comments.go +++ b/core/comments.go @@ -11,9 +11,11 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" "github.com/gorilla/sessions" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" ) // CommentRoutes attaches the comment routes to the app. @@ -91,14 +93,14 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject } // Get the template snippet. - filepath, err := b.ResolvePath("comments/comments.partial") + filepath, err := render.ResolvePath("comments/comments.partial") if err != nil { log.Error(err.Error()) return template.HTML("[error: missing comments/comments.partial]") } // And the comment view partial. - entryPartial, err := b.ResolvePath("comments/entry.partial") + entryPartial, err := render.ResolvePath("comments/entry.partial") if err != nil { log.Error(err.Error()) return template.HTML("[error: missing comments/entry.partial]") diff --git a/core/core.go b/core/core.go index 5a48ecb..60da7e7 100644 --- a/core/core.go +++ b/core/core.go @@ -8,11 +8,13 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/sessions" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/posts" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/jsondb" "github.com/kirsle/blog/jsondb/caches" "github.com/kirsle/blog/jsondb/caches/null" @@ -41,28 +43,43 @@ type Blog struct { // New initializes the Blog application. func New(documentRoot, userRoot string) *Blog { - blog := &Blog{ + return &Blog{ DocumentRoot: documentRoot, UserRoot: userRoot, DB: jsondb.New(filepath.Join(userRoot, ".private")), Cache: null.New(), } +} +// Run quickly configures and starts the HTTP server. +func (b *Blog) Run(address string) { + b.Configure() + b.SetupHTTP() + b.ListenAndServe(address) +} + +// Configure initializes (or reloads) the blog's configuration, and binds the +// settings in sub-packages. +func (b *Blog) Configure() { // Load the site config, or start with defaults if not found. - settings.DB = blog.DB + settings.DB = b.DB config, err := settings.Load() if err != nil { config = settings.Defaults() } + // Bind configs in sub-packages. + render.UserRoot = &b.UserRoot + render.DocumentRoot = &b.DocumentRoot + // Initialize the session cookie store. - blog.store = sessions.NewCookieStore([]byte(config.Security.SecretKey)) + b.store = sessions.NewCookieStore([]byte(config.Security.SecretKey)) users.HashCost = config.Security.HashCost // Initialize the rest of the models. - posts.DB = blog.DB - users.DB = blog.DB - comments.DB = blog.DB + posts.DB = b.DB + users.DB = b.DB + comments.DB = b.DB // Redis cache? if config.Redis.Enabled { @@ -76,41 +93,42 @@ func New(documentRoot, userRoot string) *Blog { if err != nil { log.Error("Redis init error: %s", err.Error()) } else { - blog.Cache = cache - blog.DB.Cache = cache + b.Cache = cache + b.DB.Cache = cache markdown.Cache = cache } } +} +// SetupHTTP initializes the Negroni middleware engine and registers routes. +func (b *Blog) SetupHTTP() { // Initialize the router. r := mux.NewRouter() - r.HandleFunc("/initial-setup", blog.SetupHandler) - blog.AuthRoutes(r) - blog.AdminRoutes(r) - blog.ContactRoutes(r) - blog.BlogRoutes(r) - blog.CommentRoutes(r) + r.HandleFunc("/initial-setup", b.SetupHandler) + b.AuthRoutes(r) + b.AdminRoutes(r) + b.ContactRoutes(r) + b.BlogRoutes(r) + b.CommentRoutes(r) // GitHub Flavored Markdown CSS. r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) - r.PathPrefix("/").HandlerFunc(blog.PageHandler) - r.NotFoundHandler = http.HandlerFunc(blog.PageHandler) + r.PathPrefix("/").HandlerFunc(b.PageHandler) + r.NotFoundHandler = http.HandlerFunc(b.PageHandler) n := negroni.New( negroni.NewRecovery(), negroni.NewLogger(), - negroni.HandlerFunc(blog.SessionLoader), - negroni.HandlerFunc(blog.CSRFMiddleware), - negroni.HandlerFunc(blog.AuthMiddleware), + negroni.HandlerFunc(b.SessionLoader), + negroni.HandlerFunc(b.CSRFMiddleware), + negroni.HandlerFunc(b.AuthMiddleware), ) n.UseHandler(r) // Keep references handy elsewhere in the app. - blog.n = n - blog.r = r - - return blog + b.n = n + b.r = r } // ListenAndServe begins listening on the given bind address. diff --git a/core/initial-setup.go b/core/initial-setup.go index 43afda5..a99e656 100644 --- a/core/initial-setup.go +++ b/core/initial-setup.go @@ -5,13 +5,15 @@ import ( "github.com/gorilla/sessions" "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" ) // SetupHandler is the initial blog setup route. func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { - vars := &Vars{ + vars := render.Vars{ Form: forms.Setup{}, } diff --git a/core/internal/log/log.go b/core/internal/log/log.go new file mode 100644 index 0000000..c7f4ea7 --- /dev/null +++ b/core/internal/log/log.go @@ -0,0 +1,30 @@ +// Package log implements the common logging engine for the blog. +package log + +import "github.com/kirsle/golog" + +var log *golog.Logger + +func init() { + log = golog.GetLogger("blog") + log.Configure(&golog.Config{ + Colors: golog.ExtendedColor, + Theme: golog.DarkTheme, + }) +} + +func Debug(m string, v ...interface{}) { + log.Debug(m, v...) +} + +func Info(m string, v ...interface{}) { + log.Info(m, v...) +} + +func Warn(m string, v ...interface{}) { + log.Warn(m, v...) +} + +func Error(m string, v ...interface{}) { + log.Error(m, v...) +} diff --git a/core/internal/markdown/markdown.go b/core/internal/markdown/markdown.go index f48b748..aa148b9 100644 --- a/core/internal/markdown/markdown.go +++ b/core/internal/markdown/markdown.go @@ -11,8 +11,8 @@ import ( "regexp" "strings" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/jsondb/caches" - "github.com/kirsle/golog" "github.com/microcosm-cc/bluemonday" "github.com/shurcooL/github_flavored_markdown" ) @@ -35,12 +35,6 @@ var ( reDecodeBlock = regexp.MustCompile(`\[?FENCED_CODE_%d_BLOCK?\]`) ) -var log *golog.Logger - -func init() { - log = golog.GetLogger("blog") -} - // A container for parsed code blocks. type codeBlock struct { placeholder int @@ -100,7 +94,10 @@ func RenderTrustedMarkdown(input string) string { // Substitute fenced codes back in. for _, block := range codeBlocks { - highlighted, _ := Pygmentize(block.language, block.source) + highlighted, err := Pygmentize(block.language, block.source) + if err != nil { + log.Error("Pygmentize error: %s", err) + } html = strings.Replace(html, fmt.Sprintf("[?FENCED_CODE_%d_BLOCK?]", block.placeholder), highlighted, @@ -127,11 +124,10 @@ func Pygmentize(language, source string) (string, error) { hash := fmt.Sprintf("%x", h.Sum(nil)) cacheKey := "pygmentize:" + hash - _ = cacheKey // Do we have it cached? - // if cached, err := b.Cache.Get(cacheKey); err == nil && len(cached) > 0 { - // return string(cached), nil - // } + if cached, err := Cache.Get(cacheKey); err == nil && len(cached) > 0 { + return string(cached), nil + } // Defer to the `pygmentize` command bin := "pygmentize" @@ -154,10 +150,10 @@ func Pygmentize(language, source string) (string, error) { } result = out.String() - // err := b.Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change - // if err != nil { - // log.Error("Couldn't cache Pygmentize output: %s", err) - // } + err := Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change + if err != nil { + log.Error("Couldn't cache Pygmentize output: %s", err) + } return result, nil } diff --git a/core/internal/render/resolve_paths.go b/core/internal/render/resolve_paths.go new file mode 100644 index 0000000..6f3b280 --- /dev/null +++ b/core/internal/render/resolve_paths.go @@ -0,0 +1,92 @@ +package render + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/kirsle/blog/core/internal/log" +) + +// Blog configuration bindings. +var ( + UserRoot *string + DocumentRoot *string +) + +// Filepath represents a file discovered in the document roots, and maintains +// both its relative and absolute components. +type Filepath struct { + // Canonicalized URI version of the file resolved on disk, + // possible with a file extension injected. + // (i.e. "/about" -> "about.html") + URI string + Basename string + Relative string // Relative path including document root (i.e. "root/about.html") + Absolute string // Absolute path on disk (i.e. "/opt/blog/root/about.html") +} + +func (f Filepath) String() string { + return f.Relative +} + +// ResolvePath matches a filesystem path to a relative request URI. +// +// This checks the UserRoot first and then the DocumentRoot. This way the user +// may override templates from the core app's document root. +func ResolvePath(path string) (Filepath, error) { + // Strip leading slashes. + if path[0] == '/' { + path = strings.TrimPrefix(path, "/") + } + + // If you need to debug this function, edit this block. + debug := func(tmpl string, args ...interface{}) { + if false { + log.Debug(tmpl, args...) + } + } + + debug("Resolving filepath for URI: %s", path) + for _, root := range []string{*UserRoot, *DocumentRoot} { + if len(root) == 0 { + continue + } + + // Resolve the file path. + relPath := filepath.Join(root, path) + absPath, err := filepath.Abs(relPath) + basename := filepath.Base(relPath) + if err != nil { + log.Error("%v", err) + } + + debug("Expected filepath: %s", absPath) + + // Found an exact hit? + if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() { + debug("Exact filepath found: %s", absPath) + return Filepath{path, basename, relPath, absPath}, nil + } + + // Try some supported suffixes. + suffixes := []string{ + ".gohtml", + ".html", + "/index.gohtml", + "/index.html", + ".md", + "/index.md", + } + for _, suffix := range suffixes { + test := absPath + suffix + if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() { + debug("Filepath found via suffix %s: %s", suffix, test) + return Filepath{path + suffix, basename + suffix, relPath + suffix, test}, nil + } + } + } + + return Filepath{}, errors.New("not found") +} diff --git a/core/internal/render/templates.go b/core/internal/render/templates.go new file mode 100644 index 0000000..3367a92 --- /dev/null +++ b/core/internal/render/templates.go @@ -0,0 +1,159 @@ +package render + +import ( + "html/template" + "io" + "net/http" + "strings" + "time" + + "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/models/users" +) + +// Config provides the settings and injectables for rendering templates. +type Config struct { + // Refined and raw variables for the templates. + Vars *Vars // Normal RenderTemplate's + + // Wrap the template with the `.layout.gohtml` + WithLayout bool + + // Inject your own functions for the Go templates. + Functions map[string]interface{} + + Request *http.Request +} + +// Vars is an interface to implement by the templates to pass their own custom +// variables in. It auto-loads global template variables (site name, etc.) +// when the template is rendered. +type Vars struct { + // Global, "constant" template variables. + SetupNeeded bool + Title string + Path string + LoggedIn bool + CurrentUser *users.User + CSRF string + Editable bool // page is editable + Request *http.Request + RequestTime time.Time + RequestDuration time.Duration + + // Configuration variables + NoLayout bool // don't wrap in .layout.html, just render the template + + // Common template variables. + Message string + Flashes []string + Error error + Data map[interface{}]interface{} + Form forms.Form +} + +// PartialTemplate handles rendering a Go template to a writer, without +// doing anything extra to the vars or dealing with net/http. This is ideal for +// rendering partials, such as comment partials. +// +// This will wrap the template in `.layout.gohtml` by default. To render just +// a bare template on its own, i.e. for partial templates, create a Vars struct +// with `Vars{NoIndex: true}` +func PartialTemplate(w io.Writer, path string, C Config) error { + if C.Request == nil { + panic("render.RenderPartialTemplate(): The *http.Request is nil!?") + } + + // v interface{}, withLayout bool, functions map[string]interface{}) error { + var ( + layout Filepath + templateName string + err error + ) + + // Find the file path to the template. + filepath, err := ResolvePath(path) + if err != nil { + log.Error("RenderTemplate(%s): file not found", path) + return err + } + + // Get the layout template. + if C.WithLayout { + templateName = "layout" + layout, err = ResolvePath(".layout") + if err != nil { + log.Error("RenderTemplate(%s): layout template not found", path) + return err + } + } else { + templateName = filepath.Basename + } + + // The comment entry partial. + commentEntry, err := ResolvePath("comments/entry.partial") + if err != nil { + log.Error("RenderTemplate(%s): comments/entry.partial not found") + return err + } + + // Template functions. + funcmap := template.FuncMap{ + "StringsJoin": strings.Join, + "Now": time.Now, + "TemplateName": func() string { + return filepath.URI + }, + } + if C.Functions != nil { + for name, fn := range C.Functions { + funcmap[name] = fn + } + } + + // Useful template functions. + t := template.New(filepath.Absolute).Funcs(funcmap) + + // Parse the template files. The layout comes first because it's the wrapper + // and allows the filepath template to set the page title. + var templates []string + if C.WithLayout { + templates = append(templates, layout.Absolute) + } + t, err = t.ParseFiles(append(templates, commentEntry.Absolute, filepath.Absolute)...) + if err != nil { + log.Error(err.Error()) + return err + } + + err = t.ExecuteTemplate(w, templateName, C.Vars) + if err != nil { + log.Error("Template parsing error: %s", err) + return err + } + + return nil +} + +// Template responds with an HTML template. +// +// The vars will be massaged a bit to load the global defaults (such as the +// website title and user login status), the user's session may be updated with +// new CSRF token, and other such things. If you just want to render a template +// without all that nonsense, use RenderPartialTemplate. +func Template(w http.ResponseWriter, path string, C Config) error { + if C.Request == nil { + panic("render.RenderTemplate(): The *http.Request is nil!?") + } + + w.Header().Set("Content-Type", "text/html; encoding=UTF-8") + PartialTemplate(w, path, Config{ + Request: C.Request, + Vars: C.Vars, + WithLayout: true, + Functions: C.Functions, + }) + + return nil +} diff --git a/core/internal/responses/responses.go b/core/internal/responses/responses.go new file mode 100644 index 0000000..1c0e193 --- /dev/null +++ b/core/internal/responses/responses.go @@ -0,0 +1,66 @@ +package responses + +import ( + "net/http" + + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/render" +) + +// Redirect sends an HTTP redirect response. +func Redirect(w http.ResponseWriter, location string) { + w.Header().Set("Location", location) + w.WriteHeader(http.StatusFound) +} + +// NotFound sends a 404 response. +func NotFound(w http.ResponseWriter, r *http.Request, message ...string) { + if len(message) == 0 { + message = []string{"The page you were looking for was not found."} + } + + w.WriteHeader(http.StatusNotFound) + err := render.RenderTemplate(w, r, ".errors/404", &render.Vars{ + Message: message[0], + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for NotFound()")) + } +} + +// Forbidden sends an HTTP 403 Forbidden response. +func Forbidden(w http.ResponseWriter, r *http.Request, message ...string) { + w.WriteHeader(http.StatusForbidden) + err := render.RenderTemplate(w, r, ".errors/403", &render.Vars{ + Message: message[0], + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for Forbidden()")) + } +} + +// Error sends an HTTP 500 Internal Server Error response. +func Error(w http.ResponseWriter, r *http.Request, message ...string) { + w.WriteHeader(http.StatusInternalServerError) + err := render.RenderTemplate(w, r, ".errors/500", &render.Vars{ + Message: message[0], + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for Error()")) + } +} + +// BadRequest sends an HTTP 400 Bad Request. +func BadRequest(w http.ResponseWriter, r *http.Request, message ...string) { + w.WriteHeader(http.StatusBadRequest) + err := render.RenderTemplate(w, r, ".errors/400", &render.Vars{ + Message: message[0], + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for BadRequest()")) + } +} diff --git a/core/log.go b/core/log.go deleted file mode 100644 index 8189ada..0000000 --- a/core/log.go +++ /dev/null @@ -1,13 +0,0 @@ -package core - -import "github.com/kirsle/golog" - -var log *golog.Logger - -func init() { - log = golog.GetLogger("blog") - log.Configure(&golog.Config{ - Colors: golog.ExtendedColor, - Theme: golog.DarkTheme, - }) -} diff --git a/core/mail.go b/core/mail.go index 6c0f750..f6f5552 100644 --- a/core/mail.go +++ b/core/mail.go @@ -8,9 +8,11 @@ import ( "net/url" "strings" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/render" "github.com/microcosm-cc/bluemonday" gomail "gopkg.in/gomail.v2" ) @@ -36,7 +38,7 @@ func (b *Blog) SendEmail(email Email) { } // Resolve the template. - tmpl, err := b.ResolvePath(email.Template) + tmpl, err := render.ResolvePath(email.Template) if err != nil { log.Error("SendEmail: %s", err.Error()) return diff --git a/core/middleware.go b/core/middleware.go index db552c6..5c86749 100644 --- a/core/middleware.go +++ b/core/middleware.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/sessions" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/models/users" ) @@ -38,6 +39,10 @@ func (b *Blog) SessionLoader(w http.ResponseWriter, r *http.Request, next http.H // Session returns the current request's session. func (b *Blog) Session(r *http.Request) *sessions.Session { + if r == nil { + panic("Session(*http.Request) with a nil argument!?") + } + ctx := r.Context() if session, ok := ctx.Value(sessionKey).(*sessions.Session); ok { return session diff --git a/core/pages.go b/core/pages.go index 6024ca0..1b7444b 100644 --- a/core/pages.go +++ b/core/pages.go @@ -1,15 +1,13 @@ package core import ( - "errors" "html/template" "io/ioutil" "net/http" - "os" - "path/filepath" "strings" "github.com/kirsle/blog/core/internal/markdown" + "github.com/kirsle/blog/core/internal/render" ) // PageHandler is the catch-all route handler, for serving static web pages. @@ -30,7 +28,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { } // Search for a file that matches their URL. - filepath, err := b.ResolvePath(path) + filepath, err := render.ResolvePath(path) if err != nil { // See if it resolves as a blog entry. err = b.viewPost(w, r, strings.TrimLeft(path, "/")) @@ -42,7 +40,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Is it a template file? if strings.HasSuffix(filepath.URI, ".gohtml") { - b.RenderTemplate(w, r, filepath.URI, nil) + b.RenderTemplate(w, r, filepath.URI, render.Vars{}) return } @@ -69,79 +67,3 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filepath.Absolute) } - -// Filepath represents a file discovered in the document roots, and maintains -// both its relative and absolute components. -type Filepath struct { - // Canonicalized URI version of the file resolved on disk, - // possible with a file extension injected. - // (i.e. "/about" -> "about.html") - URI string - Basename string - Relative string // Relative path including document root (i.e. "root/about.html") - Absolute string // Absolute path on disk (i.e. "/opt/blog/root/about.html") -} - -func (f Filepath) String() string { - return f.Relative -} - -// ResolvePath matches a filesystem path to a relative request URI. -// -// This checks the UserRoot first and then the DocumentRoot. This way the user -// may override templates from the core app's document root. -func (b *Blog) ResolvePath(path string) (Filepath, error) { - // Strip leading slashes. - if path[0] == '/' { - path = strings.TrimPrefix(path, "/") - } - - // If you need to debug this function, edit this block. - debug := func(tmpl string, args ...interface{}) { - if false { - log.Debug(tmpl, args...) - } - } - - debug("Resolving filepath for URI: %s", path) - for _, root := range []string{b.UserRoot, b.DocumentRoot} { - if len(root) == 0 { - continue - } - - // Resolve the file path. - relPath := filepath.Join(root, path) - absPath, err := filepath.Abs(relPath) - basename := filepath.Base(relPath) - if err != nil { - log.Error("%v", err) - } - - debug("Expected filepath: %s", absPath) - - // Found an exact hit? - if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() { - debug("Exact filepath found: %s", absPath) - return Filepath{path, basename, relPath, absPath}, nil - } - - // Try some supported suffixes. - suffixes := []string{ - ".gohtml", - ".html", - "/index.gohtml", - "/index.html", - ".md", - "/index.md", - } - for _, suffix := range suffixes { - test := absPath + suffix - if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() { - debug("Filepath found via suffix %s: %s", suffix, test) - return Filepath{path + suffix, basename + suffix, relPath + suffix, test}, nil - } - } - } - - return Filepath{}, errors.New("not found") -} diff --git a/core/responses.go b/core/responses.go index 92e0ebb..02f7720 100644 --- a/core/responses.go +++ b/core/responses.go @@ -3,6 +3,9 @@ package core import ( "fmt" "net/http" + + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/render" ) // Flash adds a flash message to the user's session. @@ -37,7 +40,7 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin } w.WriteHeader(http.StatusNotFound) - err := b.RenderTemplate(w, r, ".errors/404", &Vars{ + err := b.RenderTemplate(w, r, ".errors/404", render.Vars{ Message: message[0], }) if err != nil { @@ -49,7 +52,7 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin // Forbidden sends an HTTP 403 Forbidden response. func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) { w.WriteHeader(http.StatusForbidden) - err := b.RenderTemplate(w, r, ".errors/403", &Vars{ + err := b.RenderTemplate(w, r, ".errors/403", render.Vars{ Message: message[0], }) if err != nil { @@ -61,7 +64,7 @@ func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...stri // Error sends an HTTP 500 Internal Server Error response. func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message ...string) { w.WriteHeader(http.StatusInternalServerError) - err := b.RenderTemplate(w, r, ".errors/500", &Vars{ + err := b.RenderTemplate(w, r, ".errors/500", render.Vars{ Message: message[0], }) if err != nil { @@ -73,7 +76,7 @@ func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message ...string) // BadRequest sends an HTTP 400 Bad Request. func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message ...string) { w.WriteHeader(http.StatusBadRequest) - err := b.RenderTemplate(w, r, ".errors/400", &Vars{ + err := b.RenderTemplate(w, r, ".errors/400", render.Vars{ Message: message[0], }) if err != nil { diff --git a/core/templates.go b/core/templates.go index e79c6e5..3c36eea 100644 --- a/core/templates.go +++ b/core/templates.go @@ -10,6 +10,7 @@ import ( "github.com/kirsle/blog/core/internal/forms" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" ) // Vars is an interface to implement by the templates to pass their own custom @@ -41,20 +42,20 @@ type Vars struct { // NewVars initializes a Vars struct with the custom Data map initialized. // You may pass in an initial value for this map if you want. -func NewVars(data ...map[interface{}]interface{}) *Vars { +func NewVars(data ...map[interface{}]interface{}) render.Vars { var value map[interface{}]interface{} if len(data) > 0 { value = data[0] } else { value = make(map[interface{}]interface{}) } - return &Vars{ + return render.Vars{ Data: value, } } // LoadDefaults combines template variables with default, globally available vars. -func (v *Vars) LoadDefaults(b *Blog, r *http.Request) { +func (b *Blog) LoadDefaults(v render.Vars, r *http.Request) render.Vars { // Get the site settings. s, err := settings.Load() if err != nil { @@ -72,12 +73,9 @@ func (v *Vars) LoadDefaults(b *Blog, r *http.Request) { user, err := b.CurrentUser(r) v.CurrentUser = user v.LoggedIn = err == nil -} -// // TemplateVars is an interface that describes the template variable struct. -// type TemplateVars interface { -// LoadDefaults(*Blog, *http.Request) -// } + return v +} // RenderPartialTemplate handles rendering a Go template to a writer, without // doing anything extra to the vars or dealing with net/http. This is ideal for @@ -86,78 +84,14 @@ func (v *Vars) LoadDefaults(b *Blog, r *http.Request) { // This will wrap the template in `.layout.gohtml` by default. To render just // a bare template on its own, i.e. for partial templates, create a Vars struct // with `Vars{NoIndex: true}` -func (b *Blog) RenderPartialTemplate(w io.Writer, path string, v interface{}, withLayout bool, functions map[string]interface{}) error { - var ( - layout Filepath - templateName string - err error - ) - - // Find the file path to the template. - filepath, err := b.ResolvePath(path) - if err != nil { - log.Error("RenderTemplate(%s): file not found", path) - return err - } - - // Get the layout template. - if withLayout { - templateName = "layout" - layout, err = b.ResolvePath(".layout") - if err != nil { - log.Error("RenderTemplate(%s): layout template not found", path) - return err - } - } else { - templateName = filepath.Basename - } - - // The comment entry partial. - commentEntry, err := b.ResolvePath("comments/entry.partial") - if err != nil { - log.Error("RenderTemplate(%s): comments/entry.partial not found") - return err - } - - // Template functions. - funcmap := template.FuncMap{ - "StringsJoin": strings.Join, - "Now": time.Now, - "RenderIndex": b.RenderIndex, - "RenderPost": b.RenderPost, - "RenderTags": b.RenderTags, - "TemplateName": func() string { - return filepath.URI - }, - } - if functions != nil { - for name, fn := range functions { - funcmap[name] = fn - } - } - - // Useful template functions. - t := template.New(filepath.Absolute).Funcs(funcmap) - - // Parse the template files. The layout comes first because it's the wrapper - // and allows the filepath template to set the page title. - var templates []string - if withLayout { - templates = append(templates, layout.Absolute) - } - t, err = t.ParseFiles(append(templates, commentEntry.Absolute, filepath.Absolute)...) - if err != nil { - log.Error(err.Error()) - return err - } - - err = t.ExecuteTemplate(w, templateName, v) - if err != nil { - log.Error("Template parsing error: %s", err) - return err - } - - return nil +func (b *Blog) RenderPartialTemplate(w io.Writer, r *http.Request, path string, v render.Vars, withLayout bool, functions map[string]interface{}) error { + v = b.LoadDefaults(v, r) + return render.PartialTemplate(w, path, render.Config{ + Request: r, + Vars: &v, + WithLayout: withLayout, + Functions: b.TemplateFuncs(nil, nil, functions), + }) } // RenderTemplate responds with an HTML template. @@ -166,12 +100,13 @@ func (b *Blog) RenderPartialTemplate(w io.Writer, path string, v interface{}, wi // website title and user login status), the user's session may be updated with // new CSRF token, and other such things. If you just want to render a template // without all that nonsense, use RenderPartialTemplate. -func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path string, vars *Vars) error { - // Inject globally available variables. - if vars == nil { - vars = &Vars{} +func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path string, vars render.Vars) error { + if r == nil { + panic("core.RenderTemplate(): the *http.Request is nil!?") } - vars.LoadDefaults(b, r) + + // Inject globally available variables. + vars = b.LoadDefaults(vars, r) // Add any flashed messages from the endpoint controllers. session := b.Session(r) @@ -187,14 +122,34 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin vars.CSRF = b.GenerateCSRFToken(w, r, session) vars.Editable = !strings.HasPrefix(path, "admin/") - w.Header().Set("Content-Type", "text/html; encoding=UTF-8") - b.RenderPartialTemplate(w, path, vars, true, template.FuncMap{ + return render.Template(w, path, render.Config{ + Request: r, + Vars: &vars, + Functions: b.TemplateFuncs(w, r, nil), + }) +} + +// TemplateFuncs returns the common template function map. +func (b *Blog) TemplateFuncs(w http.ResponseWriter, r *http.Request, inject map[string]interface{}) map[string]interface{} { + fn := map[string]interface{}{ + "RenderIndex": b.RenderIndex, + "RenderPost": b.RenderPost, + "RenderTags": b.RenderTags, "RenderComments": func(subject string, ids ...string) template.HTML { + if w == nil || r == nil { + return template.HTML("[RenderComments Error: need both http.ResponseWriter and http.Request]") + } + session := b.Session(r) csrf := b.GenerateCSRFToken(w, r, session) return b.RenderComments(session, csrf, r.URL.Path, subject, ids...) }, - }) + } - return nil + if inject != nil { + for k, v := range inject { + fn[k] = v + } + } + return fn } diff --git a/root/blog/entry.gohtml b/root/blog/entry.gohtml index 46a094a..35364ed 100644 --- a/root/blog/entry.gohtml +++ b/root/blog/entry.gohtml @@ -2,7 +2,7 @@ {{ define "content" }} {{ $p := .Data.Post }} -{{ RenderPost $p false 0 }} +{{ RenderPost .Request $p false 0 }} {{ if and .LoggedIn .CurrentUser.Admin }} diff --git a/root/blog/entry.partial.gohtml b/root/blog/entry.partial.gohtml index 65a4715..8aadab7 100644 --- a/root/blog/entry.partial.gohtml +++ b/root/blog/entry.partial.gohtml @@ -1,7 +1,8 @@ -{{ $a := .Author }} -{{ $p := .Post }} +{{ $a := .Data.Author }} +{{ $p := .Data.Post }} +{{ $d := .Data }} -{{ if .IndexView }} +{{ if $d.IndexView }} {{ $p.Title }} {{ else }}

{{ $p.Title }}

@@ -27,9 +28,9 @@
- {{ .Rendered }} + {{ $d.Rendered }} - {{ if .Snipped }} + {{ if $d.Snipped }}

Read more...

@@ -45,9 +46,9 @@ {{ end }} -{{ if .IndexView }} +{{ if $d.IndexView }} - {{ .NumComments }} comment{{ if ne .NumComments 1 }}s{{ end }} + {{ $d.NumComments }} comment{{ if ne $d.NumComments 1 }}s{{ end }} | Permalink diff --git a/root/blog/index.partial.gohtml b/root/blog/index.partial.gohtml index ecf161b..0782916 100644 --- a/root/blog/index.partial.gohtml +++ b/root/blog/index.partial.gohtml @@ -1,18 +1,23 @@ +{{ $PreviousPage := .Data.PreviousPage }} +{{ $NextPage := .Data.NextPage }} +{{ $View := .Data.View }}
    - {{ if .PreviousPage }} -
  • Earlier
  • + {{ if $PreviousPage }} +
  • Earlier
  • {{ end }} - {{ if .NextPage }} -
  • Older
  • + {{ if $NextPage }} +
  • Older
  • {{ end }}
-{{ range .View }} +{{ $r := .Request }} + +{{ range $View }} {{ $p := .Post }} - {{ RenderPost $p true .NumComments }} + {{ RenderPost $r $p true .NumComments }} {{ if and $.LoggedIn $.CurrentUser.Admin }}
@@ -31,11 +36,11 @@
    - {{ if .PreviousPage }} -
  • Earlier
  • + {{ if $PreviousPage }} +
  • Earlier
  • {{ end }} - {{ if .NextPage }} -
  • Older
  • + {{ if $NextPage }} +
  • Older
  • {{ end }}
diff --git a/root/blog/tags.partial.gohtml b/root/blog/tags.partial.gohtml index ba0c8fc..5c7fecf 100644 --- a/root/blog/tags.partial.gohtml +++ b/root/blog/tags.partial.gohtml @@ -1,16 +1,16 @@ -{{ if .IndexView }} +{{ if .Data.IndexView }} Sorted by most frequently used: {{ else }} diff --git a/root/bluez/theme.css b/root/bluez/theme.css index 97e8237..3660175 100644 --- a/root/bluez/theme.css +++ b/root/bluez/theme.css @@ -24,6 +24,18 @@ h6, .h6 { color: #333; } +blockquote { + border-left: 2px solid #FF0000; + padding: 0 10px; + margin: 4px 6px; +} +blockquote blockquote { + border-left-color: #FF9900; +} +blockquote blockquote blockquote { + border-left-color: #CCCC00; +} + /* * Bootstrap tweaks and overrides */ diff --git a/root/comments/entry.partial.gohtml b/root/comments/entry.partial.gohtml index 3521194..05cf2da 100644 --- a/root/comments/entry.partial.gohtml +++ b/root/comments/entry.partial.gohtml @@ -101,7 +101,7 @@ class="form-control">{{ .Body }} You may format your message using - Markdown + GitHub Flavored Markdown syntax.
diff --git a/root/index.gohtml b/root/index.gohtml index fa80aa6..480d97f 100644 --- a/root/index.gohtml +++ b/root/index.gohtml @@ -8,4 +8,5 @@

{{ RenderIndex .Request "" "" }} + {{ end }} diff --git a/root/markdown.gohtml b/root/markdown.gohtml new file mode 100644 index 0000000..20be153 --- /dev/null +++ b/root/markdown.gohtml @@ -0,0 +1,712 @@ +{{ define "title" }}Markdown Cheatsheet{{ end }} +{{ define "content" }} + +

Markdown Cheatsheet

+ +

This is a simple reference sheet for Markdown syntax. The de facto place to find Markdown syntax is at +https://daringfireball.net/projects/markdown/syntax +but the examples here are more nicely presented.

+ +

This page just serves as a cheat sheet for Markdown syntax and their results. For descriptive paragraphs +explaining the syntax, see the page linked above.

+ +

This website uses GitHub Flavored Markdown, an +extension of Markdown that supports fenced code blocks, tables, and other features.

+ + + +

Block Elements

+ + +

Paragraphs and Line Breaks

+ +

A paragraph is defined as a group of lines of text separated from other groups +by at least one blank line. A hard return inside a paragraph doesn't get rendered +in the output.

+ +

Headers

+ +

There are two methods to declare a header in Markdown: "underline" it by +writing === or --- on the line directly below the +heading (for <h1> and <h2>, respectively), +or by prefixing the heading with # symbols. Usually the latter +option is the easiest, and you can get more levels of headers this way.

+ + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + This is an H1
+ =============

+ + This is an H2
+ ------------- +
+
+

This is an H1

+

This is an H2

+
+ + # This is an H1
+ ## This is an H2
+ #### This is an H4 +
+
+

This is an H1

+

This is an H2

+

This is an H4

+
+ +

Blockquotes

+ +

Prefix a line of text with > to "quote" it -- like in +"e-mail syntax."

+ +

You may have multiple layers of quotes by using multiple > +symbols.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,
+ > consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.
+ > Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.
+ >
+ > Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
+ > id sem consectetuer libero luctus adipiscing. +
+
+
+

This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, + consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. + Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.

+ +

Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse + id sem consectetuer libero luctus adipiscing.

+
+
+ + > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,
+ consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.
+ Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.

+ + > Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
+ id sem consectetuer libero luctus adipiscing. +
+
+
+

This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, + consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. + Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.

+ +

Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse + id sem consectetuer libero luctus adipiscing.

+
+
+ + > This is the first level of quoting.
+ >
+ >> This is nested blockquote.
+ >>> A third level.
+ >
+ > Back to the first level. +
+
+
+ This is the first level of quoting. +
+ This is nested blockquote. +
+ A third level. +
+
+ Back to the first level. +
+
+ + > ## This is a header.
+ >
+ > 1. This is the first list item.
+ > 2. This is the second list item.
+ >
+ >Here's some example code:
+ >
+ >    return shell_exec("echo $input | $markdown_script"); +
+
+
+

This is a header.

+
    +
  1. This is the first list item.
  2. +
  3. This is the second list item.
  4. +
+ Here's some example code: +
return shell_exec("echo $input | $markdown_script");
+
+
+ + +

Lists

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + * Red
+ * Green
+ * Blue +
+
+
    +
  • Red
  • +
  • Green
  • +
  • Blue
  • +
+
+ + + Red
+ + Green
+ + Blue +
+
+
    +
  • Red
  • +
  • Green
  • +
  • Blue
  • +
+
+ + - Red
+ - Green
+ - Blue +
+
+
    +
  • Red
  • +
  • Green
  • +
  • Blue
  • +
+
+ + 1. Bird
+ 2. McHale
+ 3. Parish +
+
+
    +
  1. Bird
  2. +
  3. McHale
  4. +
  5. Parish
  6. +
+
+ + 1.  This is a list item with two paragraphs. Lorem ipsum dolor
+     sit amet, consectetuer adipiscing elit. Aliquam hendrerit
+     mi posuere lectus.

+ +     Vestibulum enim wisi, viverra nec, fringilla in, laoreet
+     vitae, risus. Donec sit amet nisl. Aliquam semper ipsum
+     sit amet velit.

+ + 2.  Suspendisse id sem consectetuer libero luctus adipiscing. + +

+
    +
  1. This is a list item with two paragraphs. Lorem ipsum dolor + sit amet, consectetuer adipiscing elit. Aliquam hendrerit + mi posuere lectus.

    + + Vestibulum enim wisi, viverra nec, fringilla in, laoreet + vitae, risus. Donec sit amet nisl. Aliquam semper ipsum + sit amet velit.

  2. + +
  3. Suspendisse id sem consectetuer libero luctus adipiscing.
  4. +
+
+ + +

Code Blocks

+ +The typical Markdown way to write a code block is to indent each line of a paragraph with at +least 4 spaces or 1 tab character. The Rophako CMS also uses GitHub-style code blocks, where +you can use three backticks before and after the code block and then you don't need to indent +each line of the code (makes copying/pasting easier!)

+ +Like GitHub-flavored Markdown, with a fenced code block you can also specify a programming +language to get syntax highlighting for the code.

+ + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + This is a normal paragraph.

+ +     This is a code block. + +

+ This is a normal paragraph.

+ +

This is a code block
+
+ + This is a normal paragraph.

+ + ```
+ This is a GitHub style "fenced code block".
+ ``` +
+

+ This is a normal paragraph.

+ +

This is a GitHub style "fenced code block".
+
+ + ```javascript
+ document.writeln("Hello world.");
+ ``` +
+
+
document.writeln("Hello world.");
+
+ + +

Horizontal Rules

+ + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + * * *

+ ***

+ *****

+ - - -

+ --------------------------- + +

+

+


+


+


+


+
+ + + +

Span Elements

+ + +

Links

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + This is [an example](http://example.com/ "Title") inline link.

+ [This link](http://example.net/) has no title attribute. + +

+ This is an example inline link.

+ This link has no title attribute. +

+ + See my [About](/about) page for details. + + + See my About page for details. +
+ + This is [an example][id] reference-style link.

+ [id]: http://example.com/ "Optional Title Here" + +

+ This is an example reference-style link. +
+ + This is an example of an implicit reference-style link: search [Google][] for more.

+ [Google]: http://google.com/ + +

+ This is an example of an implicit reference-style link: search Google for more. +
+ + I get 10 times more traffic from [Google] [1] than from
+ [Yahoo] [2] or [Bing] [3].

+ + [1]: http://google.com/ "Google"
+ [2]: http://search.yahoo.com/ "Yahoo Search"
+ [3]: http://bing.com/ "Bing" +
+

+ I get 10 times more traffic from Google than from + Yahoo or + Bing. +
+ + +

Emphasis

+ + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + *single asterisks*

+ _single underscores_

+ **double asterisks**

+ __double underscores__ + +

+ single asterisks

+ single underscores

+ double asterisks

+ double underscores +

+ + un*frigging*believable + + + unfriggingbelievable +
+ + \*this text is surrounded by literal asterisks\* + + + *this text is surrounded by literal asterisks* +
+ + +

Code

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + Use the `printf()` function. + + + Use the printf() function. +
+ + ``There is a literal backtick (`) here.`` + + + There is a literal backtick (`) here. +
+ + A single backtick in a code span: `` ` ``

+ A backtick-delimited string in a code span: `` `foo` `` + +

+ A single backtick in a code span: `

+ A backtick-delimited string in a code span: `foo` +

+ Please don't use any `<blink>` tags. + + Please don't use any <blink> tags. +
+ `&#8212;` is the decimal-encoded equivalent of `&mdash;`. + + &#8212; is the decimal-encoded equivalent of + &mdash;. +
+ + +

Images

+ + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ ![Alt text](/static/avatars/default.png) + + Alt text +
+ ![Alt text](/static/avatars/default.png "Optional title") + + Alt text +
+ + ![Alt text][id]

+ [id]: /static/avatars/default.png "Optional title attribute" + +

+ Alt text +
+ + +

Miscellaneous

+ + +

Automatic Links

+ +E-mail links get automatically converted into a random mess of HTML attributes to +attempt to thwart e-mail harvesting spam bots.

+ + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ <http://example.com/> + + http://example.com/ +
+ <address@example.com> + + address@example.com

+ + (Source: <a href="&#109;&#97;&#105;&#108;&#116;&#111;&#58; &#97;&#100;&#100;&#114;&#101;&#115;&#115;&#64; &#101;&#120;&#97;&#109;&#112;&#108; &#101;&#46;&#99;&#111;&#109;">&#97; &#100;&#100;&#114;&#101;&#115;&#115; &#64;&#101;&#120;&#97;&#109;&#112; &#108;&#101;&#46;&#99;&#111; &#109;</a>) +

+ + +

Backslash Escapes

+ +Use backslash characters to escape any other special characters in the Markdown syntax. For example, +\* to insert a literal asterisk so that it doesn't get mistaken for e.g. emphasized text, +a list item, etc.

+ +Markdown provides backslash escapes for the following characters:

+ +

\   backslash
+`   backtick
+*   asterisk
+_   underscore
+{}  curly braces
+[]  square brackets
+()  parenthesis
+#   hash mark
++   plus sign
+-   minus sign (hyphen)
+.   dot
+!   exclamation mark
+ +{{ end }} From 60ccaf7b35d5f92bad47e0d79b05a8487172edf8 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 10 Feb 2018 11:14:42 -0800 Subject: [PATCH 04/10] Eviscerate the last of the middleware into sub-packages --- core/admin.go | 7 +- core/auth.go | 17 +++- core/blog.go | 22 ++--- core/comments.go | 20 ++-- core/constants.go | 8 ++ core/core.go | 17 ++-- core/initial-setup.go | 4 +- core/internal/middleware/auth/auth.go | 67 +++++++++++++ core/internal/middleware/csrf.go | 55 +++++++++++ core/internal/responses/responses.go | 66 ------------- core/internal/sessions/sessions.go | 56 +++++++++++ core/internal/types/context.go | 11 +++ core/middleware.go | 133 -------------------------- core/pages.go | 2 +- core/responses.go | 23 ++--- core/templates.go | 16 ++-- 16 files changed, 266 insertions(+), 258 deletions(-) create mode 100644 core/internal/middleware/auth/auth.go create mode 100644 core/internal/middleware/csrf.go delete mode 100644 core/internal/responses/responses.go create mode 100644 core/internal/sessions/sessions.go create mode 100644 core/internal/types/context.go delete mode 100644 core/middleware.go diff --git a/core/admin.go b/core/admin.go index 8b142db..91173ea 100644 --- a/core/admin.go +++ b/core/admin.go @@ -11,6 +11,7 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/render" "github.com/urfave/negroni" @@ -24,7 +25,7 @@ func (b *Blog) AdminRoutes(r *mux.Router) { adminRouter.HandleFunc("/editor", b.EditorHandler) // r.HandleFunc("/admin", b.AdminHandler) r.PathPrefix("/admin").Handler(negroni.New( - negroni.HandlerFunc(b.LoginRequired), + negroni.HandlerFunc(auth.LoginRequired(b.MustLogin)), negroni.Wrap(adminRouter), )) } @@ -48,8 +49,8 @@ func (b *Blog) EditorHandler(w http.ResponseWriter, r *http.Request) { var ( fp string fromCore = r.FormValue("from") == "core" - saving = r.FormValue("action") == "save" - deleting = r.FormValue("action") == "delete" + saving = r.FormValue("action") == ActionSave + deleting = r.FormValue("action") == ActionDelete body = []byte{} ) diff --git a/core/auth.go b/core/auth.go index d601480..97428fa 100644 --- a/core/auth.go +++ b/core/auth.go @@ -7,7 +7,9 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/forms" "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/sessions" ) // AuthRoutes attaches the auth routes to the app. @@ -17,9 +19,16 @@ func (b *Blog) AuthRoutes(r *mux.Router) { r.HandleFunc("/account", b.AccountHandler) } +// MustLogin handles errors from the LoginRequired middleware by redirecting +// the user to the login page. +func (b *Blog) MustLogin(w http.ResponseWriter, r *http.Request) { + log.Info("MustLogin for %s", r.URL.Path) + b.Redirect(w, "/login?next="+r.URL.Path) +} + // Login logs the browser in as the given user. func (b *Blog) Login(w http.ResponseWriter, r *http.Request, u *users.User) error { - session, err := b.store.Get(r, "session") // TODO session name + session, err := sessions.Store.Get(r, "session") // TODO session name if err != nil { return err } @@ -78,7 +87,7 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { // LogoutHandler logs the user out and redirects to the home page. func (b *Blog) LogoutHandler(w http.ResponseWriter, r *http.Request) { - session, _ := b.store.Get(r, "session") + session, _ := sessions.Store.Get(r, "session") delete(session.Values, "logged-in") delete(session.Values, "user-id") session.Save(r, w) @@ -87,11 +96,11 @@ func (b *Blog) LogoutHandler(w http.ResponseWriter, r *http.Request) { // AccountHandler shows the account settings page. func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { - if !b.LoggedIn(r) { + if !auth.LoggedIn(r) { b.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!") return } - currentUser, err := b.CurrentUser(r) + currentUser, err := auth.CurrentUser(r) if err != nil { b.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!!") return diff --git a/core/blog.go b/core/blog.go index 8028596..b5ceb85 100644 --- a/core/blog.go +++ b/core/blog.go @@ -15,6 +15,7 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" + "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/posts" "github.com/kirsle/blog/core/internal/models/settings" @@ -76,19 +77,10 @@ func (b *Blog) BlogRoutes(r *mux.Router) { loginRouter.HandleFunc("/blog/private", b.PrivatePosts) r.PathPrefix("/blog").Handler( negroni.New( - negroni.HandlerFunc(b.LoginRequired), + negroni.HandlerFunc(auth.LoginRequired(b.MustLogin)), negroni.Wrap(loginRouter), ), ) - - adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(false) - r.HandleFunc("/admin", b.AdminHandler) // so as to not be "/admin/" - adminRouter.HandleFunc("/settings", b.SettingsHandler) - adminRouter.PathPrefix("/").HandlerFunc(b.PageHandler) - r.PathPrefix("/admin").Handler(negroni.New( - negroni.HandlerFunc(b.LoginRequired), - negroni.Wrap(adminRouter), - )) } // RSSHandler renders an RSS feed from the blog. @@ -214,7 +206,7 @@ func (b *Blog) RecentPosts(r *http.Request, tag, privacy string) []posts.Post { } } else { // Exclude certain posts in generic index views. - if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !b.LoggedIn(r) { + if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !auth.LoggedIn(r) { continue } else if post.Privacy == DRAFT { continue @@ -370,7 +362,7 @@ func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) { byMonth := map[string]*Archive{} for _, post := range idx.Posts { // Exclude certain posts - if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !b.LoggedIn(r) { + if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !auth.LoggedIn(r) { continue } else if post.Privacy == DRAFT { continue @@ -416,8 +408,8 @@ func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string) // Handle post privacy. if post.Privacy == PRIVATE || post.Privacy == DRAFT { - if !b.LoggedIn(r) { - b.NotFound(w, r) + if !auth.LoggedIn(r) { + b.NotFound(w, r, "That post is not public.") return nil } } @@ -517,7 +509,7 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { if err := post.Validate(); err != nil { v.Error = err } else { - author, _ := b.CurrentUser(r) + author, _ := auth.CurrentUser(r) post.AuthorID = author.ID post.Updated = time.Now().UTC() diff --git a/core/comments.go b/core/comments.go index 40066e8..9e613d4 100644 --- a/core/comments.go +++ b/core/comments.go @@ -10,12 +10,14 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" - "github.com/gorilla/sessions" + gorilla "github.com/gorilla/sessions" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" + "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/sessions" ) // CommentRoutes attaches the comment routes to the app. @@ -37,7 +39,7 @@ type CommentMeta struct { } // RenderComments renders a comment form partial and returns the HTML. -func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject string, ids ...string) template.HTML { +func (b *Blog) RenderComments(session *gorilla.Session, csrfToken, url, subject string, ids ...string) template.HTML { id := strings.Join(ids, "-") // Load their cached name and email if they posted a comment before. @@ -142,7 +144,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { return } v := NewVars() - currentUser, _ := b.CurrentUser(r) + currentUser, _ := auth.CurrentUser(r) editToken := b.GetEditToken(w, r) submit := r.FormValue("submit") @@ -193,21 +195,21 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { } // Cache their name and email in their session. - session := b.Session(r) + session := sessions.Get(r) session.Values["c.name"] = c.Name session.Values["c.email"] = c.Email session.Save(r, w) // Previewing, deleting, or posting? switch submit { - case "preview", "delete": + case ActionPreview, ActionDelete: if !c.Editing && currentUser.IsAuthenticated { c.Name = currentUser.Name c.Email = currentUser.Email c.LoadAvatar() } c.HTML = template.HTML(markdown.RenderMarkdown(c.Body)) - case "post": + case ActionPost: if err := c.Validate(); err != nil { v.Error = err } else { @@ -251,7 +253,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { v.Data["Thread"] = t v.Data["Comment"] = c v.Data["Editing"] = c.Editing - v.Data["Deleting"] = submit == "delete" + v.Data["Deleting"] = submit == ActionDelete b.RenderTemplate(w, r, "comments/index.gohtml", v) } @@ -295,7 +297,7 @@ func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) { thread := r.URL.Query().Get("t") token := r.URL.Query().Get("d") if thread == "" || token == "" { - b.BadRequest(w, r) + b.BadRequest(w, r, "Bad Request") return } @@ -315,7 +317,7 @@ func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) { // GetEditToken gets or generates an edit token from the user's session, which // allows a user to edit their comment for a short while after they post it. func (b *Blog) GetEditToken(w http.ResponseWriter, r *http.Request) string { - session := b.Session(r) + session := sessions.Get(r) if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 { return token } diff --git a/core/constants.go b/core/constants.go index ba1bcb0..a2bd76e 100644 --- a/core/constants.go +++ b/core/constants.go @@ -19,3 +19,11 @@ const ( MARKDOWN ContentType = "markdown" HTML ContentType = "html" ) + +// Common form actions. +const ( + ActionSave = "save" + ActionDelete = "delete" + ActionPreview = "preview" + ActionPost = "post" +) diff --git a/core/core.go b/core/core.go index 60da7e7..3324f16 100644 --- a/core/core.go +++ b/core/core.go @@ -7,14 +7,16 @@ import ( "path/filepath" "github.com/gorilla/mux" - "github.com/gorilla/sessions" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" + "github.com/kirsle/blog/core/internal/middleware" + "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/posts" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/sessions" "github.com/kirsle/blog/jsondb" "github.com/kirsle/blog/jsondb/caches" "github.com/kirsle/blog/jsondb/caches/null" @@ -36,9 +38,8 @@ type Blog struct { Cache caches.Cacher // Web app objects. - n *negroni.Negroni // Negroni middleware manager - r *mux.Router // Router - store sessions.Store + n *negroni.Negroni // Negroni middleware manager + r *mux.Router // Router } // New initializes the Blog application. @@ -73,7 +74,7 @@ func (b *Blog) Configure() { render.DocumentRoot = &b.DocumentRoot // Initialize the session cookie store. - b.store = sessions.NewCookieStore([]byte(config.Security.SecretKey)) + sessions.SetSecretKey([]byte(config.Security.SecretKey)) users.HashCost = config.Security.HashCost // Initialize the rest of the models. @@ -120,9 +121,9 @@ func (b *Blog) SetupHTTP() { n := negroni.New( negroni.NewRecovery(), negroni.NewLogger(), - negroni.HandlerFunc(b.SessionLoader), - negroni.HandlerFunc(b.CSRFMiddleware), - negroni.HandlerFunc(b.AuthMiddleware), + negroni.HandlerFunc(sessions.Middleware), + negroni.HandlerFunc(middleware.CSRF(b.Forbidden)), + negroni.HandlerFunc(auth.Middleware), ) n.UseHandler(r) diff --git a/core/initial-setup.go b/core/initial-setup.go index a99e656..6857365 100644 --- a/core/initial-setup.go +++ b/core/initial-setup.go @@ -3,12 +3,12 @@ package core import ( "net/http" - "github.com/gorilla/sessions" "github.com/kirsle/blog/core/internal/forms" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/sessions" ) // SetupHandler is the initial blog setup route. @@ -41,7 +41,7 @@ func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { s.Save() // Re-initialize the cookie store with the new secret key. - b.store = sessions.NewCookieStore([]byte(s.Security.SecretKey)) + sessions.SetSecretKey([]byte(s.Security.SecretKey)) log.Info("Creating admin account %s", form.Username) user := &users.User{ diff --git a/core/internal/middleware/auth/auth.go b/core/internal/middleware/auth/auth.go new file mode 100644 index 0000000..be3d87f --- /dev/null +++ b/core/internal/middleware/auth/auth.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "errors" + "net/http" + + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/sessions" + "github.com/kirsle/blog/core/internal/types" + "github.com/urfave/negroni" +) + +// CurrentUser returns the current user's object. +func CurrentUser(r *http.Request) (*users.User, error) { + session := sessions.Get(r) + if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn { + id := session.Values["user-id"].(int) + u, err := users.LoadReadonly(id) + u.IsAuthenticated = true + return u, err + } + + return &users.User{ + Admin: false, + }, errors.New("not authenticated") +} + +// LoggedIn returns whether the current user is logged in to an account. +func LoggedIn(r *http.Request) bool { + session := sessions.Get(r) + if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn { + return true + } + return false +} + +// LoginRequired is a middleware that requires a logged-in user. +func LoginRequired(onError http.HandlerFunc) negroni.HandlerFunc { + middleware := func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + ctx := r.Context() + if user, ok := ctx.Value(types.UserKey).(*users.User); ok { + if user.ID > 0 { + next(w, r) + return + } + } + + log.Info("Redirect away!") + onError(w, r) + } + + return middleware +} + +// Middleware loads the user's authentication state from their session cookie. +func Middleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + u, err := CurrentUser(r) + if err != nil { + next(w, r) + return + } + + ctx := context.WithValue(r.Context(), types.UserKey, u) + next(w, r.WithContext(ctx)) +} diff --git a/core/internal/middleware/csrf.go b/core/internal/middleware/csrf.go new file mode 100644 index 0000000..37d68b3 --- /dev/null +++ b/core/internal/middleware/csrf.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "net/http" + + "github.com/google/uuid" + gorilla "github.com/gorilla/sessions" + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/sessions" + "github.com/urfave/negroni" +) + +// CSRF is a middleware generator that enforces CSRF tokens on all POST requests. +func CSRF(onError func(http.ResponseWriter, *http.Request, string)) negroni.HandlerFunc { + middleware := func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + if r.Method == "POST" { + session := sessions.Get(r) + token := GenerateCSRFToken(w, r, session) + if token != r.FormValue("_csrf") { + log.Error("CSRF Mismatch: expected %s, got %s", r.FormValue("_csrf"), token) + onError(w, r, "Failed to validate CSRF token. Please try your request again.") + return + } + } + next(w, r) + } + + return middleware +} + +// ExampleCSRF shows how to use the CSRF handler. +func ExampleCSRF() { + // Your error handling for CSRF failures. + onError := func(w http.ResponseWriter, r *http.Request, message string) { + w.Write([]byte("CSRF Error: " + message)) + } + + // Attach the middleware. + _ = negroni.New( + negroni.NewRecovery(), + negroni.NewLogger(), + negroni.HandlerFunc(CSRF(onError)), + ) +} + +// GenerateCSRFToken generates a CSRF token for the user and puts it in their session. +func GenerateCSRFToken(w http.ResponseWriter, r *http.Request, session *gorilla.Session) string { + token, ok := session.Values["csrf"].(string) + if !ok { + token := uuid.New() + session.Values["csrf"] = token.String() + session.Save(r, w) + } + return token +} diff --git a/core/internal/responses/responses.go b/core/internal/responses/responses.go deleted file mode 100644 index 1c0e193..0000000 --- a/core/internal/responses/responses.go +++ /dev/null @@ -1,66 +0,0 @@ -package responses - -import ( - "net/http" - - "github.com/kirsle/blog/core/internal/log" - "github.com/kirsle/blog/core/internal/render" -) - -// Redirect sends an HTTP redirect response. -func Redirect(w http.ResponseWriter, location string) { - w.Header().Set("Location", location) - w.WriteHeader(http.StatusFound) -} - -// NotFound sends a 404 response. -func NotFound(w http.ResponseWriter, r *http.Request, message ...string) { - if len(message) == 0 { - message = []string{"The page you were looking for was not found."} - } - - w.WriteHeader(http.StatusNotFound) - err := render.RenderTemplate(w, r, ".errors/404", &render.Vars{ - Message: message[0], - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for NotFound()")) - } -} - -// Forbidden sends an HTTP 403 Forbidden response. -func Forbidden(w http.ResponseWriter, r *http.Request, message ...string) { - w.WriteHeader(http.StatusForbidden) - err := render.RenderTemplate(w, r, ".errors/403", &render.Vars{ - Message: message[0], - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for Forbidden()")) - } -} - -// Error sends an HTTP 500 Internal Server Error response. -func Error(w http.ResponseWriter, r *http.Request, message ...string) { - w.WriteHeader(http.StatusInternalServerError) - err := render.RenderTemplate(w, r, ".errors/500", &render.Vars{ - Message: message[0], - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for Error()")) - } -} - -// BadRequest sends an HTTP 400 Bad Request. -func BadRequest(w http.ResponseWriter, r *http.Request, message ...string) { - w.WriteHeader(http.StatusBadRequest) - err := render.RenderTemplate(w, r, ".errors/400", &render.Vars{ - Message: message[0], - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for BadRequest()")) - } -} diff --git a/core/internal/sessions/sessions.go b/core/internal/sessions/sessions.go new file mode 100644 index 0000000..3c9ec3e --- /dev/null +++ b/core/internal/sessions/sessions.go @@ -0,0 +1,56 @@ +package sessions + +import ( + "context" + "net/http" + "time" + + "github.com/gorilla/sessions" + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/types" +) + +// Store holds your cookie store information. +var Store sessions.Store + +// SetSecretKey initializes a session cookie store with the secret key. +func SetSecretKey(keyPairs ...[]byte) { + Store = sessions.NewCookieStore(keyPairs...) +} + +// Middleware gets the Gorilla session store and makes it available on the +// Request context. +// +// Middleware is the first custom middleware applied, so it takes the current +// datetime to make available later in the request and stores it on the request +// context. +func Middleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + // Store the current datetime on the request context. + ctx := context.WithValue(r.Context(), types.StartTimeKey, time.Now()) + + // Get the Gorilla session and make it available in the request context. + session, _ := Store.Get(r, "session") + ctx = context.WithValue(ctx, types.SessionKey, session) + + next(w, r.WithContext(ctx)) +} + +// Get returns the current request's session. +func Get(r *http.Request) *sessions.Session { + if r == nil { + panic("Session(*http.Request) with a nil argument!?") + } + + ctx := r.Context() + if session, ok := ctx.Value(types.SessionKey).(*sessions.Session); ok { + return session + } + + // If the session wasn't on the request, it means I broke something. + log.Error( + "Session(): didn't find session in request context! Getting it " + + "from the session store instead.", + ) + session, _ := Store.Get(r, "session") + return session +} diff --git a/core/internal/types/context.go b/core/internal/types/context.go new file mode 100644 index 0000000..67bd6a7 --- /dev/null +++ b/core/internal/types/context.go @@ -0,0 +1,11 @@ +package types + +// Key is an integer enum for context.Context keys. +type Key int + +// Key definitions. +const ( + SessionKey Key = iota // The request's cookie session object. + UserKey // The request's user data for logged-in users. + StartTimeKey // HTTP request start time. +) diff --git a/core/middleware.go b/core/middleware.go deleted file mode 100644 index 5c86749..0000000 --- a/core/middleware.go +++ /dev/null @@ -1,133 +0,0 @@ -package core - -import ( - "context" - "errors" - "net/http" - "time" - - "github.com/google/uuid" - "github.com/gorilla/sessions" - "github.com/kirsle/blog/core/internal/log" - "github.com/kirsle/blog/core/internal/models/users" -) - -type key int - -const ( - sessionKey key = iota - userKey - requestTimeKey -) - -// SessionLoader gets the Gorilla session store and makes it available on the -// Request context. -// -// SessionLoader is the first custom middleware applied, so it takes the current -// datetime to make available later in the request and stores it on the request -// context. -func (b *Blog) SessionLoader(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - // Store the current datetime on the request context. - ctx := context.WithValue(r.Context(), requestTimeKey, time.Now()) - - // Get the Gorilla session and make it available in the request context. - session, _ := b.store.Get(r, "session") - ctx = context.WithValue(ctx, sessionKey, session) - - next(w, r.WithContext(ctx)) -} - -// Session returns the current request's session. -func (b *Blog) Session(r *http.Request) *sessions.Session { - if r == nil { - panic("Session(*http.Request) with a nil argument!?") - } - - ctx := r.Context() - if session, ok := ctx.Value(sessionKey).(*sessions.Session); ok { - return session - } - - log.Error( - "Session(): didn't find session in request context! Getting it " + - "from the session store instead.", - ) - session, _ := b.store.Get(r, "session") - return session -} - -// CSRFMiddleware enforces CSRF tokens on all POST requests. -func (b *Blog) CSRFMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - if r.Method == "POST" { - session := b.Session(r) - token := b.GenerateCSRFToken(w, r, session) - if token != r.FormValue("_csrf") { - log.Error("CSRF Mismatch: expected %s, got %s", r.FormValue("_csrf"), token) - b.Forbidden(w, r, "Failed to validate CSRF token. Please try your request again.") - return - } - } - - next(w, r) -} - -// GenerateCSRFToken generates a CSRF token for the user and puts it in their session. -func (b *Blog) GenerateCSRFToken(w http.ResponseWriter, r *http.Request, session *sessions.Session) string { - token, ok := session.Values["csrf"].(string) - if !ok { - token := uuid.New() - session.Values["csrf"] = token.String() - session.Save(r, w) - } - return token -} - -// CurrentUser returns the current user's object. -func (b *Blog) CurrentUser(r *http.Request) (*users.User, error) { - session := b.Session(r) - if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn { - id := session.Values["user-id"].(int) - u, err := users.LoadReadonly(id) - u.IsAuthenticated = true - return u, err - } - - return &users.User{ - Admin: false, - }, errors.New("not authenticated") -} - -// LoggedIn returns whether the current user is logged in to an account. -func (b *Blog) LoggedIn(r *http.Request) bool { - session := b.Session(r) - if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn { - return true - } - return false -} - -// AuthMiddleware loads the user's authentication state. -func (b *Blog) AuthMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - u, err := b.CurrentUser(r) - if err != nil { - next(w, r) - return - } - - ctx := context.WithValue(r.Context(), userKey, u) - next(w, r.WithContext(ctx)) -} - -// LoginRequired is a middleware that requires a logged-in user. -func (b *Blog) LoginRequired(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - ctx := r.Context() - if user, ok := ctx.Value(userKey).(*users.User); ok { - if user.ID > 0 { - next(w, r) - return - } - } - - log.Info("Redirect away!") - b.Redirect(w, "/login?next="+r.URL.Path) -} diff --git a/core/pages.go b/core/pages.go index 1b7444b..a6f20b2 100644 --- a/core/pages.go +++ b/core/pages.go @@ -23,7 +23,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Restrict special paths. if strings.HasPrefix(strings.ToLower(path), "/.") { - b.Forbidden(w, r) + b.Forbidden(w, r, "Forbidden") return } diff --git a/core/responses.go b/core/responses.go index 02f7720..79e85b2 100644 --- a/core/responses.go +++ b/core/responses.go @@ -6,11 +6,12 @@ import ( "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/sessions" ) // Flash adds a flash message to the user's session. func (b *Blog) Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { - session := b.Session(r) + session := sessions.Get(r) session.AddFlash(fmt.Sprintf(message, args...)) session.Save(r, w) } @@ -34,14 +35,14 @@ func (b *Blog) Redirect(w http.ResponseWriter, location string) { } // NotFound sends a 404 response. -func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...string) { - if len(message) == 0 { - message = []string{"The page you were looking for was not found."} +func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message string) { + if message == "" { + message = "The page you were looking for was not found." } w.WriteHeader(http.StatusNotFound) err := b.RenderTemplate(w, r, ".errors/404", render.Vars{ - Message: message[0], + Message: message, }) if err != nil { log.Error(err.Error()) @@ -50,10 +51,10 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin } // Forbidden sends an HTTP 403 Forbidden response. -func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) { +func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message string) { w.WriteHeader(http.StatusForbidden) err := b.RenderTemplate(w, r, ".errors/403", render.Vars{ - Message: message[0], + Message: message, }) if err != nil { log.Error(err.Error()) @@ -62,10 +63,10 @@ func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...stri } // Error sends an HTTP 500 Internal Server Error response. -func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message ...string) { +func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message string) { w.WriteHeader(http.StatusInternalServerError) err := b.RenderTemplate(w, r, ".errors/500", render.Vars{ - Message: message[0], + Message: message, }) if err != nil { log.Error(err.Error()) @@ -74,10 +75,10 @@ func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message ...string) } // BadRequest sends an HTTP 400 Bad Request. -func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message ...string) { +func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message string) { w.WriteHeader(http.StatusBadRequest) err := b.RenderTemplate(w, r, ".errors/400", render.Vars{ - Message: message[0], + Message: message, }) if err != nil { log.Error(err.Error()) diff --git a/core/templates.go b/core/templates.go index 3c36eea..b8759ec 100644 --- a/core/templates.go +++ b/core/templates.go @@ -8,9 +8,13 @@ import ( "time" "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/middleware" + "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/sessions" + "github.com/kirsle/blog/core/internal/types" ) // Vars is an interface to implement by the templates to pass their own custom @@ -66,11 +70,11 @@ func (b *Blog) LoadDefaults(v render.Vars, r *http.Request) render.Vars { v.SetupNeeded = true } v.Request = r - v.RequestTime = r.Context().Value(requestTimeKey).(time.Time) + v.RequestTime = r.Context().Value(types.StartTimeKey).(time.Time) v.Title = s.Site.Title v.Path = r.URL.Path - user, err := b.CurrentUser(r) + user, err := auth.CurrentUser(r) v.CurrentUser = user v.LoggedIn = err == nil @@ -109,7 +113,7 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin vars = b.LoadDefaults(vars, r) // Add any flashed messages from the endpoint controllers. - session := b.Session(r) + session := sessions.Get(r) if flashes := session.Flashes(); len(flashes) > 0 { for _, flash := range flashes { _ = flash @@ -119,7 +123,7 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin } vars.RequestDuration = time.Now().Sub(vars.RequestTime) - vars.CSRF = b.GenerateCSRFToken(w, r, session) + vars.CSRF = middleware.GenerateCSRFToken(w, r, session) vars.Editable = !strings.HasPrefix(path, "admin/") return render.Template(w, path, render.Config{ @@ -140,8 +144,8 @@ func (b *Blog) TemplateFuncs(w http.ResponseWriter, r *http.Request, inject map[ return template.HTML("[RenderComments Error: need both http.ResponseWriter and http.Request]") } - session := b.Session(r) - csrf := b.GenerateCSRFToken(w, r, session) + session := sessions.Get(r) + csrf := middleware.GenerateCSRFToken(w, r, session) return b.RenderComments(session, csrf, r.URL.Path, subject, ids...) }, } From f0045ae2cff14e05b500e8fdb64383b56d1baa56 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 10 Feb 2018 11:20:27 -0800 Subject: [PATCH 05/10] Move Flash and Redirect responses to subpackage --- core/admin.go | 13 ++++++----- core/auth.go | 29 ++++++++++++------------ core/blog.go | 15 +++++++------ core/comments.go | 21 +++++++++--------- core/contact.go | 7 +++--- core/{responses.go => errors.go} | 27 ----------------------- core/initial-setup.go | 5 +++-- core/internal/responses/responses.go | 33 ++++++++++++++++++++++++++++ core/pages.go | 3 ++- 9 files changed, 83 insertions(+), 70 deletions(-) rename core/{responses.go => errors.go} (63%) create mode 100644 core/internal/responses/responses.go diff --git a/core/admin.go b/core/admin.go index 91173ea..8f2a748 100644 --- a/core/admin.go +++ b/core/admin.go @@ -14,6 +14,7 @@ import ( "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" "github.com/urfave/negroni" ) @@ -60,18 +61,18 @@ func (b *Blog) EditorHandler(w http.ResponseWriter, r *http.Request) { body = []byte(r.FormValue("body")) err := ioutil.WriteFile(fp, body, 0644) if err != nil { - b.Flash(w, r, "Error saving: %s", err) + responses.Flash(w, r, "Error saving: %s", err) } else { - b.FlashAndRedirect(w, r, "/admin/editor?file="+url.QueryEscape(file), "Page saved successfully!") + responses.FlashAndRedirect(w, r, "/admin/editor?file="+url.QueryEscape(file), "Page saved successfully!") return } } else if deleting { fp = filepath.Join(b.UserRoot, file) err := os.Remove(fp) if err != nil { - b.FlashAndRedirect(w, r, "/admin/editor", "Error deleting: %s", err) + responses.FlashAndRedirect(w, r, "/admin/editor", "Error deleting: %s", err) } else { - b.FlashAndRedirect(w, r, "/admin/editor", "Page deleted!") + responses.FlashAndRedirect(w, r, "/admin/editor", "Page deleted!") return } } else { @@ -94,7 +95,7 @@ func (b *Blog) EditorHandler(w http.ResponseWriter, r *http.Request) { if !os.IsNotExist(err) && !f.IsDir() { body, err = ioutil.ReadFile(fp) if err != nil { - b.Flash(w, r, "Error reading %s: %s", fp, err) + responses.Flash(w, r, "Error reading %s: %s", fp, err) } } @@ -225,7 +226,7 @@ func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) { settings.Save() b.Configure() - b.FlashAndReload(w, r, "Settings have been saved!") + responses.FlashAndReload(w, r, "Settings have been saved!") return } } diff --git a/core/auth.go b/core/auth.go index 97428fa..fd8cf92 100644 --- a/core/auth.go +++ b/core/auth.go @@ -9,6 +9,7 @@ import ( "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/responses" "github.com/kirsle/blog/core/internal/sessions" ) @@ -23,7 +24,7 @@ func (b *Blog) AuthRoutes(r *mux.Router) { // the user to the login page. func (b *Blog) MustLogin(w http.ResponseWriter, r *http.Request) { log.Info("MustLogin for %s", r.URL.Path) - b.Redirect(w, "/login?next="+r.URL.Path) + responses.Redirect(w, "/login?next="+r.URL.Path) } // Login logs the browser in as the given user. @@ -67,15 +68,15 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { vars.Error = errors.New("bad username or password") } else { // Login OK! - b.Flash(w, r, "Login OK!") + responses.Flash(w, r, "Login OK!") b.Login(w, r, user) // A next URL given? TODO: actually get to work log.Info("Redirect after login to: %s", nextURL) if len(nextURL) > 0 && nextURL[0] == '/' { - b.Redirect(w, nextURL) + responses.Redirect(w, nextURL) } else { - b.Redirect(w, "/") + responses.Redirect(w, "/") } return } @@ -91,25 +92,25 @@ func (b *Blog) LogoutHandler(w http.ResponseWriter, r *http.Request) { delete(session.Values, "logged-in") delete(session.Values, "user-id") session.Save(r, w) - b.Redirect(w, "/") + responses.Redirect(w, "/") } // AccountHandler shows the account settings page. func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { if !auth.LoggedIn(r) { - b.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!") + responses.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!") return } currentUser, err := auth.CurrentUser(r) if err != nil { - b.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!!") + responses.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!!") return } // Load an editable copy of the user. user, err := users.Load(currentUser.ID) if err != nil { - b.FlashAndRedirect(w, r, "/login?next=/account", "User ID %d not loadable?", currentUser.ID) + responses.FlashAndRedirect(w, r, "/login?next=/account", "User ID %d not loadable?", currentUser.ID) return } @@ -129,14 +130,14 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { form.NewPassword = r.FormValue("newpassword") form.NewPassword2 = r.FormValue("newpassword2") if err = form.Validate(); err != nil { - b.Flash(w, r, err.Error()) + responses.Flash(w, r, err.Error()) } else { var ok = true // Validate the username is available. if form.Username != user.Username { if _, err = users.LoadUsername(form.Username); err == nil { - b.Flash(w, r, "That username already exists.") + responses.Flash(w, r, "That username already exists.") ok = false } } @@ -145,12 +146,12 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { if len(form.OldPassword) > 0 { // Validate their old password. if _, err = users.CheckAuth(form.Username, form.OldPassword); err != nil { - b.Flash(w, r, "Your old password is incorrect.") + responses.Flash(w, r, "Your old password is incorrect.") ok = false } else { err = user.SetPassword(form.NewPassword) if err != nil { - b.Flash(w, r, "Change password error: %s", err) + responses.Flash(w, r, "Change password error: %s", err) ok = false } } @@ -163,9 +164,9 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { user.Email = form.Email err = user.Save() if err != nil { - b.Flash(w, r, "Error saving user: %s", err) + responses.Flash(w, r, "Error saving user: %s", err) } else { - b.FlashAndRedirect(w, r, "/account", "Settings saved!") + responses.FlashAndRedirect(w, r, "/account", "Settings saved!") return } } diff --git a/core/blog.go b/core/blog.go index b5ceb85..4708d99 100644 --- a/core/blog.go +++ b/core/blog.go @@ -21,6 +21,7 @@ import ( "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" "github.com/urfave/negroni" ) @@ -57,7 +58,7 @@ func (b *Blog) BlogRoutes(r *mux.Router) { b.NotFound(w, r, "Not Found") return } - b.Redirect(w, "/tagged/"+tag) + responses.Redirect(w, "/tagged/"+tag) }) r.HandleFunc("/blog/entry/{fragment}", func(w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) @@ -66,7 +67,7 @@ func (b *Blog) BlogRoutes(r *mux.Router) { b.NotFound(w, r, "Not Found") return } - b.Redirect(w, "/"+fragment) + responses.Redirect(w, "/"+fragment) }) // Login-required routers. @@ -517,8 +518,8 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { if err != nil { v.Error = err } else { - b.Flash(w, r, "Post created!") - b.Redirect(w, "/"+post.Fragment) + responses.Flash(w, r, "Post created!") + responses.Redirect(w, "/"+post.Fragment) } } } @@ -542,7 +543,7 @@ func (b *Blog) DeletePost(w http.ResponseWriter, r *http.Request) { idStr = r.URL.Query().Get("id") } if idStr == "" { - b.FlashAndRedirect(w, r, "/admin", "No post ID given for deletion!") + responses.FlashAndRedirect(w, r, "/admin", "No post ID given for deletion!") return } @@ -551,14 +552,14 @@ func (b *Blog) DeletePost(w http.ResponseWriter, r *http.Request) { if err == nil { post, err = posts.Load(id) if err != nil { - b.FlashAndRedirect(w, r, "/admin", "That post ID was not found.") + responses.FlashAndRedirect(w, r, "/admin", "That post ID was not found.") return } } if r.Method == http.MethodPost { post.Delete() - b.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!") + responses.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!") return } diff --git a/core/comments.go b/core/comments.go index 9e613d4..8af2624 100644 --- a/core/comments.go +++ b/core/comments.go @@ -17,6 +17,7 @@ import ( "github.com/kirsle/blog/core/internal/models/comments" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" "github.com/kirsle/blog/core/internal/sessions" ) @@ -152,7 +153,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { c := &comments.Comment{} c.ParseForm(r) if c.ThreadID == "" { - b.FlashAndRedirect(w, r, "/", "No thread ID found in the comment form.") + responses.FlashAndRedirect(w, r, "/", "No thread ID found in the comment form.") return } @@ -173,13 +174,13 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { id := r.FormValue("id") c, err = t.Find(id) if err != nil { - b.FlashAndRedirect(w, r, "/", "That comment was not found.") + responses.FlashAndRedirect(w, r, "/", "That comment was not found.") return } // Verify they have the matching edit token. Admin users are allowed. if c.EditToken != editToken && !currentUser.Admin { - b.FlashAndRedirect(w, r, origin, "You don't have permission to edit that comment.") + responses.FlashAndRedirect(w, r, origin, "You don't have permission to edit that comment.") return } @@ -190,7 +191,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { // Are we deleting said post? if submit == "confirm-delete" { t.Delete(c.ID) - b.FlashAndRedirect(w, r, origin, "Comment deleted!") + responses.FlashAndRedirect(w, r, origin, "Comment deleted!") return } @@ -227,7 +228,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { // Append their comment. err := t.Post(c) if err != nil { - b.FlashAndRedirect(w, r, c.OriginURL, "Error posting comment: %s", err) + responses.FlashAndRedirect(w, r, c.OriginURL, "Error posting comment: %s", err) return } b.NotifyComment(c) @@ -237,14 +238,14 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { if _, err := mail.ParseAddress(c.Email); err == nil { m := comments.LoadMailingList() m.Subscribe(t.ID, c.Email) - b.FlashAndRedirect(w, r, c.OriginURL, + responses.FlashAndRedirect(w, r, c.OriginURL, "Comment posted, and you've been subscribed to "+ "future comments on this page.", ) return } } - b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!") + responses.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!") log.Info("t: %v", t.Comments) return } @@ -273,7 +274,7 @@ func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) { m := comments.LoadMailingList() m.UnsubscribeAll(email) - b.FlashAndRedirect(w, r, "/comments/subscription", + responses.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed from all mailing lists.", ) return @@ -285,7 +286,7 @@ func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) { if thread != "" && email != "" { m := comments.LoadMailingList() m.Unsubscribe(thread, email) - b.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.") + responses.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.") return } @@ -311,7 +312,7 @@ func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) { t.Delete(c.ID) } - b.FlashAndRedirect(w, r, "/", "Comment deleted!") + responses.FlashAndRedirect(w, r, "/", "Comment deleted!") } // GetEditToken gets or generates an edit token from the user's session, which diff --git a/core/contact.go b/core/contact.go index f4cb61e..11a8dcc 100644 --- a/core/contact.go +++ b/core/contact.go @@ -12,6 +12,7 @@ import ( "github.com/kirsle/blog/core/internal/forms" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/responses" ) // ContactRoutes attaches the contact URL to the app. @@ -38,7 +39,7 @@ func (b *Blog) ContactRoutes(r *mux.Router) { if r.Method == http.MethodPost { form.ParseForm(r) if err = form.Validate(); err != nil { - b.Flash(w, r, err.Error()) + responses.Flash(w, r, err.Error()) } else { go b.SendEmail(Email{ To: cfg.Site.AdminEmail, @@ -56,7 +57,7 @@ func (b *Blog) ContactRoutes(r *mux.Router) { // Log it to disk, too. fh, err := os.OpenFile(filepath.Join(b.UserRoot, ".contact.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { - b.Flash(w, r, "Error logging the message to disk: %s", err) + responses.Flash(w, r, "Error logging the message to disk: %s", err) } else { fh.WriteString(fmt.Sprintf( "Date: %s\nName: %s\nEmail: %s\nSubject: %s\n\n%s\n\n--------------------\n\n", @@ -68,7 +69,7 @@ func (b *Blog) ContactRoutes(r *mux.Router) { )) fh.Close() } - b.FlashAndRedirect(w, r, "/contact", "Your message has been sent.") + responses.FlashAndRedirect(w, r, "/contact", "Your message has been sent.") } } diff --git a/core/responses.go b/core/errors.go similarity index 63% rename from core/responses.go rename to core/errors.go index 79e85b2..3219985 100644 --- a/core/responses.go +++ b/core/errors.go @@ -1,39 +1,12 @@ package core import ( - "fmt" "net/http" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/render" - "github.com/kirsle/blog/core/internal/sessions" ) -// Flash adds a flash message to the user's session. -func (b *Blog) Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { - session := sessions.Get(r) - session.AddFlash(fmt.Sprintf(message, args...)) - session.Save(r, w) -} - -// FlashAndRedirect flashes and redirects in one go. -func (b *Blog) FlashAndRedirect(w http.ResponseWriter, r *http.Request, location, message string, args ...interface{}) { - b.Flash(w, r, message, args...) - b.Redirect(w, location) -} - -// FlashAndReload flashes and sends a redirect to the same path. -func (b *Blog) FlashAndReload(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { - b.Flash(w, r, message, args...) - b.Redirect(w, r.URL.Path) -} - -// Redirect sends an HTTP redirect response. -func (b *Blog) Redirect(w http.ResponseWriter, location string) { - w.Header().Set("Location", location) - w.WriteHeader(http.StatusFound) -} - // NotFound sends a 404 response. func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message string) { if message == "" { diff --git a/core/initial-setup.go b/core/initial-setup.go index 6857365..3ad754b 100644 --- a/core/initial-setup.go +++ b/core/initial-setup.go @@ -8,6 +8,7 @@ import ( "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" "github.com/kirsle/blog/core/internal/sessions" ) @@ -20,7 +21,7 @@ func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { // Reject if we're already set up. s, _ := settings.Load() if s.Initialized { - b.FlashAndRedirect(w, r, "/", "This website has already been configured.") + responses.FlashAndRedirect(w, r, "/", "This website has already been configured.") return } @@ -58,7 +59,7 @@ func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { // All set! b.Login(w, r, user) - b.FlashAndRedirect(w, r, "/admin", "Admin user created and logged in.") + responses.FlashAndRedirect(w, r, "/admin", "Admin user created and logged in.") return } } diff --git a/core/internal/responses/responses.go b/core/internal/responses/responses.go new file mode 100644 index 0000000..8776768 --- /dev/null +++ b/core/internal/responses/responses.go @@ -0,0 +1,33 @@ +package responses + +import ( + "fmt" + "net/http" + + "github.com/kirsle/blog/core/internal/sessions" +) + +// Flash adds a flash message to the user's session. +func Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { + session := sessions.Get(r) + session.AddFlash(fmt.Sprintf(message, args...)) + session.Save(r, w) +} + +// FlashAndRedirect flashes and redirects in one go. +func FlashAndRedirect(w http.ResponseWriter, r *http.Request, location, message string, args ...interface{}) { + Flash(w, r, message, args...) + Redirect(w, location) +} + +// FlashAndReload flashes and sends a redirect to the same path. +func FlashAndReload(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { + Flash(w, r, message, args...) + Redirect(w, r.URL.Path) +} + +// Redirect sends an HTTP redirect response. +func Redirect(w http.ResponseWriter, location string) { + w.Header().Set("Location", location) + w.WriteHeader(http.StatusFound) +} diff --git a/core/pages.go b/core/pages.go index a6f20b2..c2ce736 100644 --- a/core/pages.go +++ b/core/pages.go @@ -8,6 +8,7 @@ import ( "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" ) // PageHandler is the catch-all route handler, for serving static web pages. @@ -17,7 +18,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Remove trailing slashes by redirecting them away. if len(path) > 1 && path[len(path)-1] == '/' { - b.Redirect(w, strings.TrimRight(path, "/")) + responses.Redirect(w, strings.TrimRight(path, "/")) return } From eab7dae75b839a21c06bf6724a44562ccd0f3200 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 10 Feb 2018 13:16:20 -0800 Subject: [PATCH 06/10] Further simplify template rendering --- core/admin.go | 2 +- core/auth.go | 1 - core/blog.go | 26 ++--- core/comments.go | 12 +- core/internal/controllers/setup/setup.go | 1 + core/internal/middleware/auth/auth.go | 3 - core/internal/render/functions.go | 14 +++ core/internal/render/templates.go | 125 ++++++++++----------- core/pages.go | 2 +- core/templates.go | 135 ++--------------------- root/.layout.gohtml | 2 +- root/blog/entry.gohtml | 2 +- 12 files changed, 105 insertions(+), 220 deletions(-) create mode 100644 core/internal/controllers/setup/setup.go create mode 100644 core/internal/render/functions.go diff --git a/core/admin.go b/core/admin.go index 8f2a748..b910951 100644 --- a/core/admin.go +++ b/core/admin.go @@ -33,7 +33,7 @@ func (b *Blog) AdminRoutes(r *mux.Router) { // AdminHandler is the admin landing page. func (b *Blog) AdminHandler(w http.ResponseWriter, r *http.Request) { - b.RenderTemplate(w, r, "admin/index", render.Vars{}) + render.Template(w, r, "admin/index", NewVars()) } // FileTree holds information about files in the document roots. diff --git a/core/auth.go b/core/auth.go index fd8cf92..29efd4b 100644 --- a/core/auth.go +++ b/core/auth.go @@ -23,7 +23,6 @@ func (b *Blog) AuthRoutes(r *mux.Router) { // MustLogin handles errors from the LoginRequired middleware by redirecting // the user to the login page. func (b *Blog) MustLogin(w http.ResponseWriter, r *http.Request) { - log.Info("MustLogin for %s", r.URL.Path) responses.Redirect(w, "/login?next="+r.URL.Path) } diff --git a/core/blog.go b/core/blog.go index 4708d99..de2e5d3 100644 --- a/core/blog.go +++ b/core/blog.go @@ -44,6 +44,10 @@ type Archive struct { // BlogRoutes attaches the blog routes to the app. func (b *Blog) BlogRoutes(r *mux.Router) { + render.Funcs["RenderIndex"] = b.RenderIndex + render.Funcs["RenderPost"] = b.RenderPost + render.Funcs["RenderTags"] = b.RenderTags + // Public routes r.HandleFunc("/blog", b.IndexHandler) r.HandleFunc("/blog.rss", b.RSSHandler) @@ -302,19 +306,14 @@ func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML { // Render the blog index partial. var output bytes.Buffer v := render.Vars{ + NoLayout: true, Data: map[interface{}]interface{}{ "PreviousPage": previousPage, "NextPage": nextPage, "View": view, }, } - v = b.LoadDefaults(v, r) - render.PartialTemplate(&output, "blog/index.partial", render.Config{ - Request: r, - Vars: &v, - WithLayout: false, - Functions: b.TemplateFuncs(nil, r, nil), - }) + b.RenderTemplate(&output, r, "blog/index.partial", v) return template.HTML(output.String()) } @@ -333,19 +332,13 @@ func (b *Blog) RenderTags(r *http.Request, indexView bool) template.HTML { var output bytes.Buffer v := render.Vars{ + NoLayout: true, Data: map[interface{}]interface{}{ "IndexView": indexView, "Tags": tags, }, } - v = b.LoadDefaults(v, r) - render.PartialTemplate(&output, "blog/tags.partial", render.Config{ - Request: r, - Vars: &v, - WithLayout: false, - Functions: b.TemplateFuncs(nil, nil, nil), - }) - // b.RenderPartialTemplate(&output, r, "blog/tags.partial", v, false, nil) + b.RenderTemplate(&output, r, "blog/tags.partial", v) return template.HTML(output.String()) } @@ -455,6 +448,7 @@ func (b *Blog) RenderPost(r *http.Request, p *posts.Post, indexView bool, numCom } meta := render.Vars{ + NoLayout: true, Data: map[interface{}]interface{}{ "Post": p, "Rendered": rendered, @@ -465,7 +459,7 @@ func (b *Blog) RenderPost(r *http.Request, p *posts.Post, indexView bool, numCom }, } output := bytes.Buffer{} - err = b.RenderPartialTemplate(&output, r, "blog/entry.partial", meta, false, nil) + err = b.RenderTemplate(&output, r, "blog/entry.partial", meta) if err != nil { return template.HTML(fmt.Sprintf("[template error in blog/entry.partial: %s]", err.Error())) } diff --git a/core/comments.go b/core/comments.go index 8af2624..9d10114 100644 --- a/core/comments.go +++ b/core/comments.go @@ -10,7 +10,6 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" - gorilla "github.com/gorilla/sessions" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/middleware/auth" @@ -23,6 +22,8 @@ import ( // CommentRoutes attaches the comment routes to the app. func (b *Blog) CommentRoutes(r *mux.Router) { + render.Funcs["RenderComments"] = b.RenderComments + r.HandleFunc("/comments", b.CommentHandler) r.HandleFunc("/comments/subscription", b.SubscriptionHandler) r.HandleFunc("/comments/quick-delete", b.QuickDeleteHandler) @@ -40,13 +41,16 @@ type CommentMeta struct { } // RenderComments renders a comment form partial and returns the HTML. -func (b *Blog) RenderComments(session *gorilla.Session, csrfToken, url, subject string, ids ...string) template.HTML { +func (b *Blog) RenderComments(r *http.Request, subject string, ids ...string) template.HTML { id := strings.Join(ids, "-") + session := sessions.Get(r) + url := r.URL.Path // Load their cached name and email if they posted a comment before. name, _ := session.Values["c.name"].(string) email, _ := session.Values["c.email"].(string) editToken, _ := session.Values["c.token"].(string) + csrf, _ := session.Values["csrf"].(string) // Check if the user is a logged-in admin, to make all comments editable. var isAdmin bool @@ -71,7 +75,7 @@ func (b *Blog) RenderComments(session *gorilla.Session, csrfToken, url, subject c.HTML = template.HTML(markdown.RenderMarkdown(c.Body)) c.ThreadID = thread.ID c.OriginURL = url - c.CSRF = csrfToken + c.CSRF = csrf // Look up the author username. if c.UserID > 0 { @@ -120,7 +124,7 @@ func (b *Blog) RenderComments(session *gorilla.Session, csrfToken, url, subject ID: thread.ID, OriginURL: url, Subject: subject, - CSRF: csrfToken, + CSRF: csrf, Thread: &thread, NewComment: comments.Comment{ Name: name, diff --git a/core/internal/controllers/setup/setup.go b/core/internal/controllers/setup/setup.go new file mode 100644 index 0000000..9bca696 --- /dev/null +++ b/core/internal/controllers/setup/setup.go @@ -0,0 +1 @@ +package setup diff --git a/core/internal/middleware/auth/auth.go b/core/internal/middleware/auth/auth.go index be3d87f..dad1fd8 100644 --- a/core/internal/middleware/auth/auth.go +++ b/core/internal/middleware/auth/auth.go @@ -5,7 +5,6 @@ import ( "errors" "net/http" - "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/sessions" "github.com/kirsle/blog/core/internal/types" @@ -46,8 +45,6 @@ func LoginRequired(onError http.HandlerFunc) negroni.HandlerFunc { return } } - - log.Info("Redirect away!") onError(w, r) } diff --git a/core/internal/render/functions.go b/core/internal/render/functions.go new file mode 100644 index 0000000..9e18b3c --- /dev/null +++ b/core/internal/render/functions.go @@ -0,0 +1,14 @@ +package render + +import ( + "html/template" + "strings" + "time" +) + +// Funcs is a global funcmap that the blog can hook its internal +// methods onto. +var Funcs = template.FuncMap{ + "StringsJoin": strings.Join, + "Now": time.Now, +} diff --git a/core/internal/render/templates.go b/core/internal/render/templates.go index 3367a92..0b80619 100644 --- a/core/internal/render/templates.go +++ b/core/internal/render/templates.go @@ -9,23 +9,14 @@ import ( "github.com/kirsle/blog/core/internal/forms" "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/middleware" + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/sessions" + "github.com/kirsle/blog/core/internal/types" ) -// Config provides the settings and injectables for rendering templates. -type Config struct { - // Refined and raw variables for the templates. - Vars *Vars // Normal RenderTemplate's - - // Wrap the template with the `.layout.gohtml` - WithLayout bool - - // Inject your own functions for the Go templates. - Functions map[string]interface{} - - Request *http.Request -} - // Vars is an interface to implement by the templates to pass their own custom // variables in. It auto-loads global template variables (site name, etc.) // when the template is rendered. @@ -34,6 +25,7 @@ type Vars struct { SetupNeeded bool Title string Path string + TemplatePath string LoggedIn bool CurrentUser *users.User CSRF string @@ -53,18 +45,58 @@ type Vars struct { Form forms.Form } -// PartialTemplate handles rendering a Go template to a writer, without -// doing anything extra to the vars or dealing with net/http. This is ideal for -// rendering partials, such as comment partials. -// -// This will wrap the template in `.layout.gohtml` by default. To render just -// a bare template on its own, i.e. for partial templates, create a Vars struct -// with `Vars{NoIndex: true}` -func PartialTemplate(w io.Writer, path string, C Config) error { - if C.Request == nil { - panic("render.RenderPartialTemplate(): The *http.Request is nil!?") +// loadDefaults combines template variables with default, globally available vars. +func (v *Vars) loadDefaults(r *http.Request) { + // Get the site settings. + s, err := settings.Load() + if err != nil { + s = settings.Defaults() } + if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup") { + v.SetupNeeded = true + } + v.Request = r + v.RequestTime = r.Context().Value(types.StartTimeKey).(time.Time) + v.Title = s.Site.Title + v.Path = r.URL.Path + + user, err := auth.CurrentUser(r) + v.CurrentUser = user + v.LoggedIn = err == nil +} + +// Template responds with an HTML template. +// +// The vars will be massaged a bit to load the global defaults (such as the +// website title and user login status), the user's session may be updated with +// new CSRF token, and other such things. If you just want to render a template +// without all that nonsense, use RenderPartialTemplate. +func Template(w io.Writer, r *http.Request, path string, v Vars) error { + // Inject globally available variables. + v.loadDefaults(r) + + // If this is the HTTP response, handle session-related things. + if rw, ok := w.(http.ResponseWriter); ok { + rw.Header().Set("Content-Type", "text/html; encoding=UTF-8") + session := sessions.Get(r) + + // Flashed messages. + if flashes := session.Flashes(); len(flashes) > 0 { + for _, flash := range flashes { + _ = flash + v.Flashes = append(v.Flashes, flash.(string)) + } + session.Save(r, rw) + } + + // CSRF token for forms. + v.CSRF = middleware.GenerateCSRFToken(rw, r, session) + } + + v.RequestDuration = time.Now().Sub(v.RequestTime) + v.Editable = !strings.HasPrefix(path, "admin/") + // v interface{}, withLayout bool, functions map[string]interface{}) error { var ( layout Filepath @@ -80,7 +112,7 @@ func PartialTemplate(w io.Writer, path string, C Config) error { } // Get the layout template. - if C.WithLayout { + if !v.NoLayout { templateName = "layout" layout, err = ResolvePath(".layout") if err != nil { @@ -98,27 +130,12 @@ func PartialTemplate(w io.Writer, path string, C Config) error { return err } - // Template functions. - funcmap := template.FuncMap{ - "StringsJoin": strings.Join, - "Now": time.Now, - "TemplateName": func() string { - return filepath.URI - }, - } - if C.Functions != nil { - for name, fn := range C.Functions { - funcmap[name] = fn - } - } - - // Useful template functions. - t := template.New(filepath.Absolute).Funcs(funcmap) + t := template.New(filepath.Absolute).Funcs(Funcs) // Parse the template files. The layout comes first because it's the wrapper // and allows the filepath template to set the page title. var templates []string - if C.WithLayout { + if !v.NoLayout { templates = append(templates, layout.Absolute) } t, err = t.ParseFiles(append(templates, commentEntry.Absolute, filepath.Absolute)...) @@ -127,7 +144,7 @@ func PartialTemplate(w io.Writer, path string, C Config) error { return err } - err = t.ExecuteTemplate(w, templateName, C.Vars) + err = t.ExecuteTemplate(w, templateName, v) if err != nil { log.Error("Template parsing error: %s", err) return err @@ -135,25 +152,3 @@ func PartialTemplate(w io.Writer, path string, C Config) error { return nil } - -// Template responds with an HTML template. -// -// The vars will be massaged a bit to load the global defaults (such as the -// website title and user login status), the user's session may be updated with -// new CSRF token, and other such things. If you just want to render a template -// without all that nonsense, use RenderPartialTemplate. -func Template(w http.ResponseWriter, path string, C Config) error { - if C.Request == nil { - panic("render.RenderTemplate(): The *http.Request is nil!?") - } - - w.Header().Set("Content-Type", "text/html; encoding=UTF-8") - PartialTemplate(w, path, Config{ - Request: C.Request, - Vars: C.Vars, - WithLayout: true, - Functions: C.Functions, - }) - - return nil -} diff --git a/core/pages.go b/core/pages.go index c2ce736..7bea655 100644 --- a/core/pages.go +++ b/core/pages.go @@ -41,7 +41,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Is it a template file? if strings.HasSuffix(filepath.URI, ".gohtml") { - b.RenderTemplate(w, r, filepath.URI, render.Vars{}) + b.RenderTemplate(w, r, filepath.URI, NewVars()) return } diff --git a/core/templates.go b/core/templates.go index b8759ec..07d45bf 100644 --- a/core/templates.go +++ b/core/templates.go @@ -1,49 +1,12 @@ package core import ( - "html/template" "io" "net/http" - "strings" - "time" - "github.com/kirsle/blog/core/internal/forms" - "github.com/kirsle/blog/core/internal/middleware" - "github.com/kirsle/blog/core/internal/middleware/auth" - "github.com/kirsle/blog/core/internal/models/settings" - "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" - "github.com/kirsle/blog/core/internal/sessions" - "github.com/kirsle/blog/core/internal/types" ) -// Vars is an interface to implement by the templates to pass their own custom -// variables in. It auto-loads global template variables (site name, etc.) -// when the template is rendered. -type Vars struct { - // Global, "constant" template variables. - SetupNeeded bool - Title string - Path string - LoggedIn bool - CurrentUser *users.User - CSRF string - Editable bool // page is editable - Request *http.Request - RequestTime time.Time - RequestDuration time.Duration - - // Configuration variables - NoLayout bool // don't wrap in .layout.html, just render the template - - // Common template variables. - Message string - Flashes []string - Error error - Data map[interface{}]interface{} - Form forms.Form -} - // NewVars initializes a Vars struct with the custom Data map initialized. // You may pass in an initial value for this map if you want. func NewVars(data ...map[interface{}]interface{}) render.Vars { @@ -58,102 +21,20 @@ func NewVars(data ...map[interface{}]interface{}) render.Vars { } } -// LoadDefaults combines template variables with default, globally available vars. -func (b *Blog) LoadDefaults(v render.Vars, r *http.Request) render.Vars { - // Get the site settings. - s, err := settings.Load() - if err != nil { - s = settings.Defaults() - } - - if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup") { - v.SetupNeeded = true - } - v.Request = r - v.RequestTime = r.Context().Value(types.StartTimeKey).(time.Time) - v.Title = s.Site.Title - v.Path = r.URL.Path - - user, err := auth.CurrentUser(r) - v.CurrentUser = user - v.LoggedIn = err == nil - - return v -} - -// RenderPartialTemplate handles rendering a Go template to a writer, without -// doing anything extra to the vars or dealing with net/http. This is ideal for -// rendering partials, such as comment partials. -// -// This will wrap the template in `.layout.gohtml` by default. To render just -// a bare template on its own, i.e. for partial templates, create a Vars struct -// with `Vars{NoIndex: true}` -func (b *Blog) RenderPartialTemplate(w io.Writer, r *http.Request, path string, v render.Vars, withLayout bool, functions map[string]interface{}) error { - v = b.LoadDefaults(v, r) - return render.PartialTemplate(w, path, render.Config{ - Request: r, - Vars: &v, - WithLayout: withLayout, - Functions: b.TemplateFuncs(nil, nil, functions), - }) -} - // RenderTemplate responds with an HTML template. // // The vars will be massaged a bit to load the global defaults (such as the // website title and user login status), the user's session may be updated with -// new CSRF token, and other such things. If you just want to render a template -// without all that nonsense, use RenderPartialTemplate. -func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path string, vars render.Vars) error { +// new CSRF token, and other such things. +// +// For server-rendered templates given directly to the user (i.e., in controllers), +// give it the http.ResponseWriter; for partial templates you can give it a +// bytes.Buffer to write to instead. The subtle difference is whether or not the +// template will have access to the request's session. +func (b *Blog) RenderTemplate(w io.Writer, r *http.Request, path string, vars render.Vars) error { if r == nil { panic("core.RenderTemplate(): the *http.Request is nil!?") } - // Inject globally available variables. - vars = b.LoadDefaults(vars, r) - - // Add any flashed messages from the endpoint controllers. - session := sessions.Get(r) - if flashes := session.Flashes(); len(flashes) > 0 { - for _, flash := range flashes { - _ = flash - vars.Flashes = append(vars.Flashes, flash.(string)) - } - session.Save(r, w) - } - - vars.RequestDuration = time.Now().Sub(vars.RequestTime) - vars.CSRF = middleware.GenerateCSRFToken(w, r, session) - vars.Editable = !strings.HasPrefix(path, "admin/") - - return render.Template(w, path, render.Config{ - Request: r, - Vars: &vars, - Functions: b.TemplateFuncs(w, r, nil), - }) -} - -// TemplateFuncs returns the common template function map. -func (b *Blog) TemplateFuncs(w http.ResponseWriter, r *http.Request, inject map[string]interface{}) map[string]interface{} { - fn := map[string]interface{}{ - "RenderIndex": b.RenderIndex, - "RenderPost": b.RenderPost, - "RenderTags": b.RenderTags, - "RenderComments": func(subject string, ids ...string) template.HTML { - if w == nil || r == nil { - return template.HTML("[RenderComments Error: need both http.ResponseWriter and http.Request]") - } - - session := sessions.Get(r) - csrf := middleware.GenerateCSRFToken(w, r, session) - return b.RenderComments(session, csrf, r.URL.Path, subject, ids...) - }, - } - - if inject != nil { - for k, v := range inject { - fn[k] = v - } - } - return fn + return render.Template(w, r, path, vars) } diff --git a/root/.layout.gohtml b/root/.layout.gohtml index fa8d95c..84d1eee 100644 --- a/root/.layout.gohtml +++ b/root/.layout.gohtml @@ -81,7 +81,7 @@ {{ if and .CurrentUser.Admin .Editable }}

- Admin: [edit this page] + Admin: [edit this page]

{{ end }}
diff --git a/root/blog/entry.gohtml b/root/blog/entry.gohtml index 35364ed..410ad65 100644 --- a/root/blog/entry.gohtml +++ b/root/blog/entry.gohtml @@ -18,7 +18,7 @@

Comments

{{ $idStr := printf "%d" $p.ID}} - {{ RenderComments $p.Title "post" $idStr }} + {{ RenderComments .Request $p.Title "post" $idStr }} {{ else }}
Comments are disabled on this post. From 6d3de7da691f1170adf7993e8596a6b904b92ff3 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 10 Feb 2018 14:05:41 -0800 Subject: [PATCH 07/10] Let me use interface{} for template vars Since most of the `render.Vars{}` fields were hardcoded/not really editable for the templates, apart from .Data, this struct is now locked away in the render subpackage. End http.HandlerFunc's can then make any arbitrary template data structure they want to, available inside the templates as `.Data`. --- core/admin.go | 24 ++++----- core/auth.go | 23 ++++---- core/blog.go | 87 ++++++++++++++---------------- core/comments.go | 26 ++++----- core/contact.go | 10 ++-- core/errors.go | 16 +++--- core/initial-setup.go | 18 +++---- core/internal/forms/forms.go | 6 +-- core/internal/forms/setup.go | 8 +++ core/internal/markdown/markdown.go | 14 +++-- core/internal/render/templates.go | 64 ++++++++++------------ core/pages.go | 8 +-- core/templates.go | 40 -------------- root/.errors/400.gohtml | 2 +- root/.errors/403.gohtml | 2 +- root/.errors/404.gohtml | 2 +- root/.errors/500.gohtml | 2 +- root/.layout.gohtml | 4 +- root/.markdown.gohtml | 6 +++ root/contact.gohtml | 11 ++-- root/initial-setup.gohtml | 3 +- 21 files changed, 173 insertions(+), 203 deletions(-) delete mode 100644 core/templates.go diff --git a/core/admin.go b/core/admin.go index b910951..8af40ef 100644 --- a/core/admin.go +++ b/core/admin.go @@ -33,7 +33,7 @@ func (b *Blog) AdminRoutes(r *mux.Router) { // AdminHandler is the admin landing page. func (b *Blog) AdminHandler(w http.ResponseWriter, r *http.Request) { - render.Template(w, r, "admin/index", NewVars()) + render.Template(w, r, "admin/index", nil) } // FileTree holds information about files in the document roots. @@ -106,13 +106,13 @@ func (b *Blog) EditorHandler(w http.ResponseWriter, r *http.Request) { } } - v := NewVars(map[interface{}]interface{}{ + v := map[string]interface{}{ "File": file, "Path": fp, "Body": string(body), "FromCore": fromCore, - }) - b.RenderTemplate(w, r, "admin/editor", v) + } + render.Template(w, r, "admin/editor", v) return } @@ -165,19 +165,19 @@ func (b *Blog) editorFileList(w http.ResponseWriter, r *http.Request) { trees = append(trees, tree) } - v := NewVars(map[interface{}]interface{}{ + v := map[string]interface{}{ "FileTrees": trees, - }) - b.RenderTemplate(w, r, "admin/filelist", v) + } + render.Template(w, r, "admin/filelist", v) } // SettingsHandler lets you configure the app from the frontend. func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) { - v := NewVars() - // Get the current settings. settings, _ := settings.Load() - v.Data["s"] = settings + v := map[string]interface{}{ + "s": settings, + } if r.Method == http.MethodPost { redisPort, _ := strconv.Atoi(r.FormValue("redis-port")) @@ -220,7 +220,7 @@ func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) { settings.Mail.Password = form.MailPassword err := form.Validate() if err != nil { - v.Error = err + v["Error"] = err } else { // Save the settings. settings.Save() @@ -230,5 +230,5 @@ func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) { return } } - b.RenderTemplate(w, r, "admin/settings", v) + render.Template(w, r, "admin/settings", v) } diff --git a/core/auth.go b/core/auth.go index 29efd4b..ba59417 100644 --- a/core/auth.go +++ b/core/auth.go @@ -9,6 +9,7 @@ import ( "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/core/internal/responses" "github.com/kirsle/blog/core/internal/sessions" ) @@ -40,8 +41,9 @@ func (b *Blog) Login(w http.ResponseWriter, r *http.Request, u *users.User) erro // LoginHandler shows and handles the login page. func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { - vars := NewVars() - vars.Form = forms.Setup{} + vars := map[string]interface{}{ + "Form": forms.Setup{}, + } var nextURL string if r.Method == http.MethodPost { @@ -49,22 +51,22 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { } else { nextURL = r.URL.Query().Get("next") } - vars.Data["NextURL"] = nextURL + vars["NextURL"] = nextURL if r.Method == http.MethodPost { form := &forms.Login{ Username: r.FormValue("username"), Password: r.FormValue("password"), } - vars.Form = form + vars["Form"] = form err := form.Validate() if err != nil { - vars.Error = err + vars["Error"] = err } else { // Test the login. user, err := users.CheckAuth(form.Username, form.Password) if err != nil { - vars.Error = errors.New("bad username or password") + vars["Error"] = errors.New("bad username or password") } else { // Login OK! responses.Flash(w, r, "Login OK!") @@ -82,7 +84,7 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { } } - b.RenderTemplate(w, r, "login", vars) + render.Template(w, r, "login", vars) } // LogoutHandler logs the user out and redirects to the home page. @@ -113,13 +115,14 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { return } - v := NewVars() form := &forms.Account{ Username: user.Username, Email: user.Email, Name: user.Name, } - v.Form = form + v := map[string]interface{}{ + "Form": form, + } if r.Method == http.MethodPost { form.Username = users.Normalize(r.FormValue("username")) @@ -172,5 +175,5 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { } } - b.RenderTemplate(w, r, "account", v) + render.Template(w, r, "account", v) } diff --git a/core/blog.go b/core/blog.go index de2e5d3..1955e8d 100644 --- a/core/blog.go +++ b/core/blog.go @@ -151,7 +151,7 @@ func (b *Blog) Tagged(w http.ResponseWriter, r *http.Request) { tag, ok := params["tag"] if !ok { // They're listing all the tags. - b.RenderTemplate(w, r, "blog/tags.gohtml", NewVars()) + render.Template(w, r, "blog/tags.gohtml", nil) return } @@ -182,11 +182,11 @@ func (b *Blog) CommonIndexHandler(w http.ResponseWriter, r *http.Request, tag, p title = "Blog" } - b.RenderTemplate(w, r, "blog/index", NewVars(map[interface{}]interface{}{ + render.Template(w, r, "blog/index", map[string]interface{}{ "Title": title, "Tag": tag, "Privacy": privacy, - })) + }) } // RecentPosts gets and filters the blog entries and orders them by most recent. @@ -305,15 +305,12 @@ func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML { // Render the blog index partial. var output bytes.Buffer - v := render.Vars{ - NoLayout: true, - Data: map[interface{}]interface{}{ - "PreviousPage": previousPage, - "NextPage": nextPage, - "View": view, - }, + v := map[string]interface{}{ + "PreviousPage": previousPage, + "NextPage": nextPage, + "View": view, } - b.RenderTemplate(&output, r, "blog/index.partial", v) + render.Template(&output, r, "blog/index.partial", v) return template.HTML(output.String()) } @@ -331,14 +328,11 @@ func (b *Blog) RenderTags(r *http.Request, indexView bool) template.HTML { } var output bytes.Buffer - v := render.Vars{ - NoLayout: true, - Data: map[interface{}]interface{}{ - "IndexView": indexView, - "Tags": tags, - }, + v := map[string]interface{}{ + "IndexView": indexView, + "Tags": tags, } - b.RenderTemplate(&output, r, "blog/tags.partial", v) + render.Template(&output, r, "blog/tags.partial", v) return template.HTML(output.String()) } @@ -384,10 +378,10 @@ func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) { result = append(result, byMonth[label]) } - v := NewVars(map[interface{}]interface{}{ + v := map[string]interface{}{ "Archive": result, - }) - b.RenderTemplate(w, r, "blog/archive", v) + } + render.Template(w, r, "blog/archive", v) } // viewPost is the underlying implementation of the handler to view a blog @@ -408,10 +402,10 @@ func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string) } } - v := NewVars(map[interface{}]interface{}{ + v := map[string]interface{}{ "Post": post, - }) - b.RenderTemplate(w, r, "blog/entry", v) + } + render.Template(w, r, "blog/entry", v) return nil } @@ -447,19 +441,16 @@ func (b *Blog) RenderPost(r *http.Request, p *posts.Post, indexView bool, numCom rendered = template.HTML(p.Body) } - meta := render.Vars{ - NoLayout: true, - Data: map[interface{}]interface{}{ - "Post": p, - "Rendered": rendered, - "Author": author, - "IndexView": indexView, - "Snipped": snipped, - "NumComments": numComments, - }, + meta := map[string]interface{}{ + "Post": p, + "Rendered": rendered, + "Author": author, + "IndexView": indexView, + "Snipped": snipped, + "NumComments": numComments, } output := bytes.Buffer{} - err = b.RenderTemplate(&output, r, "blog/entry.partial", meta) + err = render.Template(&output, r, "blog/entry.partial", meta) if err != nil { return template.HTML(fmt.Sprintf("[template error in blog/entry.partial: %s]", err.Error())) } @@ -469,9 +460,9 @@ func (b *Blog) RenderPost(r *http.Request, p *posts.Post, indexView bool, numCom // EditBlog is the blog writing and editing page. func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { - v := NewVars(map[interface{}]interface{}{ + v := map[string]interface{}{ "preview": "", - }) + } var post *posts.Post // Are we editing an existing post? @@ -480,7 +471,7 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { if err == nil { post, err = posts.Load(id) if err != nil { - v.Error = errors.New("that post ID was not found") + v["Error"] = errors.New("that post ID was not found") post = posts.New() } } @@ -496,13 +487,13 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { switch r.FormValue("submit") { case "preview": if post.ContentType == string(MARKDOWN) { - v.Data["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) + v["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) } else { - v.Data["preview"] = template.HTML(post.Body) + v["preview"] = template.HTML(post.Body) } case "post": if err := post.Validate(); err != nil { - v.Error = err + v["Error"] = err } else { author, _ := auth.CurrentUser(r) post.AuthorID = author.ID @@ -510,7 +501,7 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { post.Updated = time.Now().UTC() err = post.Save() if err != nil { - v.Error = err + v["Error"] = err } else { responses.Flash(w, r, "Post created!") responses.Redirect(w, "/"+post.Fragment) @@ -519,16 +510,16 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { } } - v.Data["post"] = post - b.RenderTemplate(w, r, "blog/edit", v) + v["post"] = post + render.Template(w, r, "blog/edit", v) } // DeletePost to delete a blog entry. func (b *Blog) DeletePost(w http.ResponseWriter, r *http.Request) { var post *posts.Post - v := NewVars(map[interface{}]interface{}{ + v := map[string]interface{}{ "Post": nil, - }) + } var idStr string if r.Method == http.MethodPost { @@ -557,6 +548,6 @@ func (b *Blog) DeletePost(w http.ResponseWriter, r *http.Request) { return } - v.Data["Post"] = post - b.RenderTemplate(w, r, "blog/delete", v) + v["Post"] = post + render.Template(w, r, "blog/delete", v) } diff --git a/core/comments.go b/core/comments.go index 9d10114..f6a985b 100644 --- a/core/comments.go +++ b/core/comments.go @@ -148,7 +148,6 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { b.BadRequest(w, r, "That method is not allowed.") return } - v := NewVars() currentUser, _ := auth.CurrentUser(r) editToken := b.GetEditToken(w, r) submit := r.FormValue("submit") @@ -205,6 +204,8 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { session.Values["c.email"] = c.Email session.Save(r, w) + v := map[string]interface{}{} + // Previewing, deleting, or posting? switch submit { case ActionPreview, ActionDelete: @@ -216,7 +217,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { c.HTML = template.HTML(markdown.RenderMarkdown(c.Body)) case ActionPost: if err := c.Validate(); err != nil { - v.Error = err + v["Error"] = err } else { // Store our edit token, if we don't have one. For example, admins // can edit others' comments but should not replace their edit token. @@ -255,25 +256,24 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { } } - v.Data["Thread"] = t - v.Data["Comment"] = c - v.Data["Editing"] = c.Editing - v.Data["Deleting"] = submit == ActionDelete + v["Thread"] = t + v["Comment"] = c + v["Editing"] = c.Editing + v["Deleting"] = submit == ActionDelete - b.RenderTemplate(w, r, "comments/index.gohtml", v) + render.Template(w, r, "comments/index.gohtml", v) } // SubscriptionHandler to opt out of subscriptions. func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) { - v := NewVars() - + var err error // POST to unsubscribe from all threads. if r.Method == http.MethodPost { email := r.FormValue("email") if email == "" { - v.Error = errors.New("email address is required to unsubscribe from comment threads") + err = errors.New("email address is required to unsubscribe from comment threads") } else if _, err := mail.ParseAddress(email); err != nil { - v.Error = errors.New("invalid email address") + err = errors.New("invalid email address") } m := comments.LoadMailingList() @@ -294,7 +294,9 @@ func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) { return } - b.RenderTemplate(w, r, "comments/subscription.gohtml", v) + render.Template(w, r, "comments/subscription.gohtml", map[string]error{ + "Error": err, + }) } // QuickDeleteHandler allows the admin to quickly delete spam without logging in. diff --git a/core/contact.go b/core/contact.go index 11a8dcc..8c97717 100644 --- a/core/contact.go +++ b/core/contact.go @@ -12,15 +12,17 @@ import ( "github.com/kirsle/blog/core/internal/forms" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/core/internal/responses" ) // 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 + form := &forms.Contact{} + v := map[string]interface{}{ + "Form": form, + } // If there is no site admin, show an error. cfg, err := settings.Load() @@ -73,6 +75,6 @@ func (b *Blog) ContactRoutes(r *mux.Router) { } } - b.RenderTemplate(w, r, "contact", v) + render.Template(w, r, "contact", v) }) } diff --git a/core/errors.go b/core/errors.go index 3219985..8984360 100644 --- a/core/errors.go +++ b/core/errors.go @@ -14,8 +14,8 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message string) } w.WriteHeader(http.StatusNotFound) - err := b.RenderTemplate(w, r, ".errors/404", render.Vars{ - Message: message, + err := render.Template(w, r, ".errors/404", map[string]string{ + "Message": message, }) if err != nil { log.Error(err.Error()) @@ -26,8 +26,8 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message string) // Forbidden sends an HTTP 403 Forbidden response. func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message string) { w.WriteHeader(http.StatusForbidden) - err := b.RenderTemplate(w, r, ".errors/403", render.Vars{ - Message: message, + err := render.Template(w, r, ".errors/403", map[string]string{ + "Message": message, }) if err != nil { log.Error(err.Error()) @@ -38,8 +38,8 @@ func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message string) // Error sends an HTTP 500 Internal Server Error response. func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message string) { w.WriteHeader(http.StatusInternalServerError) - err := b.RenderTemplate(w, r, ".errors/500", render.Vars{ - Message: message, + err := render.Template(w, r, ".errors/500", map[string]string{ + "Message": message, }) if err != nil { log.Error(err.Error()) @@ -50,8 +50,8 @@ func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message string) { // BadRequest sends an HTTP 400 Bad Request. func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message string) { w.WriteHeader(http.StatusBadRequest) - err := b.RenderTemplate(w, r, ".errors/400", render.Vars{ - Message: message, + err := render.Template(w, r, ".errors/400", map[string]string{ + "Message": message, }) if err != nil { log.Error(err.Error()) diff --git a/core/initial-setup.go b/core/initial-setup.go index 3ad754b..d0063f5 100644 --- a/core/initial-setup.go +++ b/core/initial-setup.go @@ -14,8 +14,9 @@ import ( // SetupHandler is the initial blog setup route. func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { - vars := render.Vars{ - Form: forms.Setup{}, + form := &forms.Setup{} + vars := map[string]interface{}{ + "Form": form, } // Reject if we're already set up. @@ -26,15 +27,10 @@ func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodPost { - form := forms.Setup{ - Username: r.FormValue("username"), - Password: r.FormValue("password"), - Confirm: r.FormValue("confirm"), - } - vars.Form = form + form.ParseForm(r) err := form.Validate() if err != nil { - vars.Error = err + vars["Error"] = err } else { // Save the site config. log.Info("Creating default website config file") @@ -54,7 +50,7 @@ func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { err := users.Create(user) if err != nil { log.Error("Error: %v", err) - vars.Error = err + vars["Error"] = err } // All set! @@ -64,5 +60,5 @@ func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { } } - b.RenderTemplate(w, r, "initial-setup", vars) + render.Template(w, r, "initial-setup", vars) } diff --git a/core/internal/forms/forms.go b/core/internal/forms/forms.go index a261e60..2e7b9ef 100644 --- a/core/internal/forms/forms.go +++ b/core/internal/forms/forms.go @@ -1,6 +1,6 @@ package forms // Form is an interface for forms that can validate themselves. -type Form interface { - Validate() error -} +// type Form interface { +// Validate() error +// } diff --git a/core/internal/forms/setup.go b/core/internal/forms/setup.go index dd76a08..18afbd1 100644 --- a/core/internal/forms/setup.go +++ b/core/internal/forms/setup.go @@ -2,6 +2,7 @@ package forms import ( "errors" + "net/http" ) // Setup is for the initial blog setup page at /initial-setup. @@ -11,6 +12,13 @@ type Setup struct { Confirm string } +// Parse form values. +func (f *Setup) ParseForm(r *http.Request) { + f.Username = r.FormValue("username") + f.Password = r.FormValue("password") + f.Confirm = r.FormValue("confirm") +} + // Validate the form. func (f Setup) Validate() error { if len(f.Username) == 0 { diff --git a/core/internal/markdown/markdown.go b/core/internal/markdown/markdown.go index aa148b9..6bb1b47 100644 --- a/core/internal/markdown/markdown.go +++ b/core/internal/markdown/markdown.go @@ -125,8 +125,10 @@ func Pygmentize(language, source string) (string, error) { cacheKey := "pygmentize:" + hash // Do we have it cached? - if cached, err := Cache.Get(cacheKey); err == nil && len(cached) > 0 { - return string(cached), nil + if Cache != nil { + if cached, err := Cache.Get(cacheKey); err == nil && len(cached) > 0 { + return string(cached), nil + } } // Defer to the `pygmentize` command @@ -150,9 +152,11 @@ func Pygmentize(language, source string) (string, error) { } result = out.String() - err := Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change - if err != nil { - log.Error("Couldn't cache Pygmentize output: %s", err) + if Cache != nil { + err := Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change + if err != nil { + log.Error("Couldn't cache Pygmentize output: %s", err) + } } return result, nil diff --git a/core/internal/render/templates.go b/core/internal/render/templates.go index 0b80619..0b7affe 100644 --- a/core/internal/render/templates.go +++ b/core/internal/render/templates.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "github.com/kirsle/blog/core/internal/forms" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/middleware" "github.com/kirsle/blog/core/internal/middleware/auth" @@ -20,12 +19,12 @@ import ( // Vars is an interface to implement by the templates to pass their own custom // variables in. It auto-loads global template variables (site name, etc.) // when the template is rendered. -type Vars struct { +type vars struct { // Global, "constant" template variables. SetupNeeded bool Title string Path string - TemplatePath string + TemplatePath string // actual template file on disk LoggedIn bool CurrentUser *users.User CSRF string @@ -34,36 +33,11 @@ type Vars struct { RequestTime time.Time RequestDuration time.Duration - // Configuration variables - NoLayout bool // don't wrap in .layout.html, just render the template - // Common template variables. Message string Flashes []string Error error - Data map[interface{}]interface{} - Form forms.Form -} - -// loadDefaults combines template variables with default, globally available vars. -func (v *Vars) loadDefaults(r *http.Request) { - // Get the site settings. - s, err := settings.Load() - if err != nil { - s = settings.Defaults() - } - - if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup") { - v.SetupNeeded = true - } - v.Request = r - v.RequestTime = r.Context().Value(types.StartTimeKey).(time.Time) - v.Title = s.Site.Title - v.Path = r.URL.Path - - user, err := auth.CurrentUser(r) - v.CurrentUser = user - v.LoggedIn = err == nil + Data interface{} } // Template responds with an HTML template. @@ -72,9 +46,30 @@ func (v *Vars) loadDefaults(r *http.Request) { // website title and user login status), the user's session may be updated with // new CSRF token, and other such things. If you just want to render a template // without all that nonsense, use RenderPartialTemplate. -func Template(w io.Writer, r *http.Request, path string, v Vars) error { +func Template(w io.Writer, r *http.Request, path string, data interface{}) error { + isPartial := strings.Contains(path, ".partial") + + // Get the site settings. + s, err := settings.Load() + if err != nil { + s = settings.Defaults() + } + // Inject globally available variables. - v.loadDefaults(r) + v := vars{ + SetupNeeded: s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup"), + + Request: r, + RequestTime: r.Context().Value(types.StartTimeKey).(time.Time), + Title: s.Site.Title, + Path: r.URL.Path, + + Data: data, + } + + user, err := auth.CurrentUser(r) + v.CurrentUser = user + v.LoggedIn = err == nil // If this is the HTTP response, handle session-related things. if rw, ok := w.(http.ResponseWriter); ok { @@ -97,11 +92,9 @@ func Template(w io.Writer, r *http.Request, path string, v Vars) error { v.RequestDuration = time.Now().Sub(v.RequestTime) v.Editable = !strings.HasPrefix(path, "admin/") - // v interface{}, withLayout bool, functions map[string]interface{}) error { var ( layout Filepath templateName string - err error ) // Find the file path to the template. @@ -110,9 +103,10 @@ func Template(w io.Writer, r *http.Request, path string, v Vars) error { log.Error("RenderTemplate(%s): file not found", path) return err } + v.TemplatePath = filepath.URI // Get the layout template. - if !v.NoLayout { + if !isPartial { templateName = "layout" layout, err = ResolvePath(".layout") if err != nil { @@ -135,7 +129,7 @@ func Template(w io.Writer, r *http.Request, path string, v Vars) error { // Parse the template files. The layout comes first because it's the wrapper // and allows the filepath template to set the page title. var templates []string - if !v.NoLayout { + if !isPartial { templates = append(templates, layout.Absolute) } t, err = t.ParseFiles(append(templates, commentEntry.Absolute, filepath.Absolute)...) diff --git a/core/pages.go b/core/pages.go index 7bea655..7e0bbbd 100644 --- a/core/pages.go +++ b/core/pages.go @@ -41,7 +41,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Is it a template file? if strings.HasSuffix(filepath.URI, ".gohtml") { - b.RenderTemplate(w, r, filepath.URI, NewVars()) + render.Template(w, r, filepath.URI, nil) return } @@ -58,11 +58,11 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { html := markdown.RenderTrustedMarkdown(body) title, _ := markdown.TitleFromMarkdown(body) - b.RenderTemplate(w, r, ".markdown", NewVars(map[interface{}]interface{}{ + render.Template(w, r, ".markdown", map[string]interface{}{ "Title": title, "HTML": template.HTML(html), - "MarkdownFile": filepath.URI, - })) + "MarkdownPath": filepath.URI, + }) return } diff --git a/core/templates.go b/core/templates.go deleted file mode 100644 index 07d45bf..0000000 --- a/core/templates.go +++ /dev/null @@ -1,40 +0,0 @@ -package core - -import ( - "io" - "net/http" - - "github.com/kirsle/blog/core/internal/render" -) - -// NewVars initializes a Vars struct with the custom Data map initialized. -// You may pass in an initial value for this map if you want. -func NewVars(data ...map[interface{}]interface{}) render.Vars { - var value map[interface{}]interface{} - if len(data) > 0 { - value = data[0] - } else { - value = make(map[interface{}]interface{}) - } - return render.Vars{ - Data: value, - } -} - -// RenderTemplate responds with an HTML template. -// -// The vars will be massaged a bit to load the global defaults (such as the -// website title and user login status), the user's session may be updated with -// new CSRF token, and other such things. -// -// For server-rendered templates given directly to the user (i.e., in controllers), -// give it the http.ResponseWriter; for partial templates you can give it a -// bytes.Buffer to write to instead. The subtle difference is whether or not the -// template will have access to the request's session. -func (b *Blog) RenderTemplate(w io.Writer, r *http.Request, path string, vars render.Vars) error { - if r == nil { - panic("core.RenderTemplate(): the *http.Request is nil!?") - } - - return render.Template(w, r, path, vars) -} diff --git a/root/.errors/400.gohtml b/root/.errors/400.gohtml index 25842a0..34ad409 100644 --- a/root/.errors/400.gohtml +++ b/root/.errors/400.gohtml @@ -2,5 +2,5 @@ {{ define "content" }}

400 Bad Request

-{{ .Message }} +{{ .Data.Message }} {{ end }} diff --git a/root/.errors/403.gohtml b/root/.errors/403.gohtml index b9832e9..61d91f8 100644 --- a/root/.errors/403.gohtml +++ b/root/.errors/403.gohtml @@ -2,5 +2,5 @@ {{ define "content" }}

403 Forbidden

-{{ .Message }} +{{ .Data.Message }} {{ end }} diff --git a/root/.errors/404.gohtml b/root/.errors/404.gohtml index aab335c..76ad398 100644 --- a/root/.errors/404.gohtml +++ b/root/.errors/404.gohtml @@ -2,7 +2,7 @@ {{ define "content" }}

404 Not Found

-{{ .Message }} +{{ .Data.Message }} {{ if .CurrentUser.Admin }}

diff --git a/root/.errors/500.gohtml b/root/.errors/500.gohtml index 2ca412f..9dcea92 100644 --- a/root/.errors/500.gohtml +++ b/root/.errors/500.gohtml @@ -2,5 +2,5 @@ {{ define "content" }}

500 Internal Server Error

-{{ .Message }} +{{ .Data.Message }} {{ end }} diff --git a/root/.layout.gohtml b/root/.layout.gohtml index 84d1eee..4060f56 100644 --- a/root/.layout.gohtml +++ b/root/.layout.gohtml @@ -79,9 +79,9 @@ {{ template "content" . }} - {{ if and .CurrentUser.Admin .Editable }} + {{ if and .CurrentUser.Admin .Editable (ne .TemplatePath ".markdown") }}

- Admin: [edit this page] + Admin: [edit this page]

{{ end }} diff --git a/root/.markdown.gohtml b/root/.markdown.gohtml index 43a7881..99c8972 100644 --- a/root/.markdown.gohtml +++ b/root/.markdown.gohtml @@ -3,4 +3,10 @@ {{ .Data.HTML }} +{{ if and .CurrentUser.Admin .Editable }} +

+ Admin: [edit this page] +

+{{ end }} + {{ end }} diff --git a/root/contact.gohtml b/root/contact.gohtml index a1af15b..66f0d99 100644 --- a/root/contact.gohtml +++ b/root/contact.gohtml @@ -7,6 +7,9 @@ administrator.

+data={{ .Data }} + +{{ $form := .Data.Form }}
@@ -19,7 +22,7 @@ class="form-control" id="name" placeholder="Anonymous" - value="{{ .Form.Name }}"> + value="{{ $form.Name }}">
@@ -28,7 +31,7 @@ class="form-control" id="email" placeholder="(if you want a response)" - value="{{ .Form.Email }}"> + value="{{ $form.Email }}">
@@ -50,7 +53,7 @@ name="message" id="message" placeholder="Message" - required>{{ .Form.Message }} + required>{{ $form.Message }}
diff --git a/root/initial-setup.gohtml b/root/initial-setup.gohtml index 27aee3e..e2bbb5e 100644 --- a/root/initial-setup.gohtml +++ b/root/initial-setup.gohtml @@ -13,6 +13,7 @@ predictable for an attacker to guess.

+{{ $form := .Data.Form }}
@@ -22,7 +23,7 @@ class="form-control" id="setup-admin-username" placeholder="Enter username" - value="{{ .Form.Username }}"> + value="{{ $form.Username }}">
From eb1880d348be64b1eef22a72ffed62cc916b1119 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 10 Feb 2018 14:36:21 -0800 Subject: [PATCH 08/10] Break out some controllers and move mail to subpackage Controllers moved into sub-packages: * Initial setup * Admin routes * Auth login/out routes * Contact --- core/admin.go | 234 ------------------ core/comments.go | 14 +- core/core.go | 19 +- core/initial-setup.go | 64 ----- core/internal/controllers/admin/admin.go | 27 ++ core/internal/controllers/admin/editor.go | 147 +++++++++++ core/internal/controllers/admin/settings.go | 72 ++++++ .../controllers/authctl/authctl.go} | 41 +-- .../controllers/contact}/contact.go | 17 +- core/internal/controllers/setup/setup.go | 69 ++++++ core/{ => internal/mail}/mail.go | 22 +- core/internal/middleware/auth/auth.go | 12 + root/account.gohtml | 9 +- root/contact.gohtml | 2 - 14 files changed, 382 insertions(+), 367 deletions(-) delete mode 100644 core/admin.go delete mode 100644 core/initial-setup.go create mode 100644 core/internal/controllers/admin/admin.go create mode 100644 core/internal/controllers/admin/editor.go create mode 100644 core/internal/controllers/admin/settings.go rename core/{auth.go => internal/controllers/authctl/authctl.go} (75%) rename core/{ => internal/controllers/contact}/contact.go (77%) rename core/{ => internal/mail}/mail.go (93%) diff --git a/core/admin.go b/core/admin.go deleted file mode 100644 index 8af40ef..0000000 --- a/core/admin.go +++ /dev/null @@ -1,234 +0,0 @@ -package core - -import ( - "io/ioutil" - "net/http" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/gorilla/mux" - "github.com/kirsle/blog/core/internal/forms" - "github.com/kirsle/blog/core/internal/middleware/auth" - "github.com/kirsle/blog/core/internal/models/settings" - "github.com/kirsle/blog/core/internal/render" - "github.com/kirsle/blog/core/internal/responses" - "github.com/urfave/negroni" -) - -// AdminRoutes attaches the admin routes to the app. -func (b *Blog) AdminRoutes(r *mux.Router) { - adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(true) - adminRouter.HandleFunc("/", b.AdminHandler) - adminRouter.HandleFunc("/settings", b.SettingsHandler) - adminRouter.HandleFunc("/editor", b.EditorHandler) - // r.HandleFunc("/admin", b.AdminHandler) - r.PathPrefix("/admin").Handler(negroni.New( - negroni.HandlerFunc(auth.LoginRequired(b.MustLogin)), - negroni.Wrap(adminRouter), - )) -} - -// AdminHandler is the admin landing page. -func (b *Blog) AdminHandler(w http.ResponseWriter, r *http.Request) { - render.Template(w, r, "admin/index", nil) -} - -// FileTree holds information about files in the document roots. -type FileTree struct { - UserRoot bool // false = CoreRoot - Files []render.Filepath -} - -// EditorHandler lets you edit web pages from the frontend. -func (b *Blog) EditorHandler(w http.ResponseWriter, r *http.Request) { - // Editing a page? - file := strings.Trim(r.FormValue("file"), "/") - if len(file) > 0 { - var ( - fp string - fromCore = r.FormValue("from") == "core" - saving = r.FormValue("action") == ActionSave - deleting = r.FormValue("action") == ActionDelete - body = []byte{} - ) - - // Are they saving? - if saving { - fp = filepath.Join(b.UserRoot, file) - body = []byte(r.FormValue("body")) - err := ioutil.WriteFile(fp, body, 0644) - if err != nil { - responses.Flash(w, r, "Error saving: %s", err) - } else { - responses.FlashAndRedirect(w, r, "/admin/editor?file="+url.QueryEscape(file), "Page saved successfully!") - return - } - } else if deleting { - fp = filepath.Join(b.UserRoot, file) - err := os.Remove(fp) - if err != nil { - responses.FlashAndRedirect(w, r, "/admin/editor", "Error deleting: %s", err) - } else { - responses.FlashAndRedirect(w, r, "/admin/editor", "Page deleted!") - return - } - } else { - // Where is the file from? - if fromCore { - fp = filepath.Join(b.DocumentRoot, file) - } else { - fp = filepath.Join(b.UserRoot, file) - } - - // Check the file. If not found, check from the core root. - f, err := os.Stat(fp) - if os.IsNotExist(err) { - fp = filepath.Join(b.DocumentRoot, file) - fromCore = true - f, err = os.Stat(fp) - } - - // If it exists, load it. - if !os.IsNotExist(err) && !f.IsDir() { - body, err = ioutil.ReadFile(fp) - if err != nil { - responses.Flash(w, r, "Error reading %s: %s", fp, err) - } - } - - // Default HTML boilerplate for .gohtml templates. - if len(body) == 0 && strings.HasSuffix(fp, ".gohtml") { - body = []byte("{{ define \"title\" }}Untitled Page{{ end }}\n" + - "{{ define \"content\" }}\n

Untitled Page

\n\n{{ end }}") - } - } - - v := map[string]interface{}{ - "File": file, - "Path": fp, - "Body": string(body), - "FromCore": fromCore, - } - render.Template(w, r, "admin/editor", v) - return - } - - // Otherwise listing the index view. - b.editorFileList(w, r) -} - -// editorFileList handles the index view of /admin/editor. -func (b *Blog) editorFileList(w http.ResponseWriter, r *http.Request) { - // Listing the file tree? - trees := []FileTree{} - for i, root := range []string{b.UserRoot, b.DocumentRoot} { - tree := FileTree{ - UserRoot: i == 0, - Files: []render.Filepath{}, - } - - filepath.Walk(root, func(path string, f os.FileInfo, err error) error { - abs, _ := filepath.Abs(path) - rel, _ := filepath.Rel(root, path) - - // Skip hidden files and directories. - if f.IsDir() || rel == "." || strings.HasPrefix(rel, ".private") || strings.HasPrefix(rel, "admin/") { - return nil - } - - // Only text files. - ext := strings.ToLower(filepath.Ext(path)) - okTypes := []string{ - ".html", ".gohtml", ".md", ".markdown", ".js", ".css", ".jsx", - } - ok := false - for _, ft := range okTypes { - if ext == ft { - ok = true - break - } - } - if !ok { - return nil - } - - tree.Files = append(tree.Files, render.Filepath{ - Absolute: abs, - Relative: rel, - Basename: filepath.Base(path), - }) - return nil - }) - - trees = append(trees, tree) - } - v := map[string]interface{}{ - "FileTrees": trees, - } - render.Template(w, r, "admin/filelist", v) -} - -// SettingsHandler lets you configure the app from the frontend. -func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) { - // Get the current settings. - settings, _ := settings.Load() - v := map[string]interface{}{ - "s": settings, - } - - if r.Method == http.MethodPost { - redisPort, _ := strconv.Atoi(r.FormValue("redis-port")) - redisDB, _ := strconv.Atoi(r.FormValue("redis-db")) - mailPort, _ := strconv.Atoi(r.FormValue("mail-port")) - form := &forms.Settings{ - Title: r.FormValue("title"), - Description: r.FormValue("description"), - AdminEmail: r.FormValue("admin-email"), - URL: r.FormValue("url"), - RedisEnabled: len(r.FormValue("redis-enabled")) > 0, - RedisHost: r.FormValue("redis-host"), - RedisPort: redisPort, - RedisDB: redisDB, - RedisPrefix: r.FormValue("redis-prefix"), - MailEnabled: len(r.FormValue("mail-enabled")) > 0, - MailSender: r.FormValue("mail-sender"), - MailHost: r.FormValue("mail-host"), - MailPort: mailPort, - MailUsername: r.FormValue("mail-username"), - MailPassword: r.FormValue("mail-password"), - } - - // Copy form values into the settings struct for display, in case of - // any validation errors. - settings.Site.Title = form.Title - settings.Site.Description = form.Description - settings.Site.AdminEmail = form.AdminEmail - settings.Site.URL = form.URL - settings.Redis.Enabled = form.RedisEnabled - settings.Redis.Host = form.RedisHost - settings.Redis.Port = form.RedisPort - settings.Redis.DB = form.RedisDB - settings.Redis.Prefix = form.RedisPrefix - settings.Mail.Enabled = form.MailEnabled - settings.Mail.Sender = form.MailSender - settings.Mail.Host = form.MailHost - settings.Mail.Port = form.MailPort - settings.Mail.Username = form.MailUsername - settings.Mail.Password = form.MailPassword - err := form.Validate() - if err != nil { - v["Error"] = err - } else { - // Save the settings. - settings.Save() - b.Configure() - - responses.FlashAndReload(w, r, "Settings have been saved!") - return - } - } - render.Template(w, r, "admin/settings", v) -} diff --git a/core/comments.go b/core/comments.go index f6a985b..ba5164a 100644 --- a/core/comments.go +++ b/core/comments.go @@ -2,15 +2,14 @@ package core import ( "bytes" - "errors" "html/template" "net/http" - "net/mail" "strings" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/mail" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/middleware/auth" "github.com/kirsle/blog/core/internal/models/comments" @@ -236,7 +235,7 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { responses.FlashAndRedirect(w, r, c.OriginURL, "Error posting comment: %s", err) return } - b.NotifyComment(c) + mail.NotifyComment(c) // Are they subscribing to future comments? if c.Subscribe && len(c.Email) > 0 { @@ -266,14 +265,13 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { // SubscriptionHandler to opt out of subscriptions. func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) { - var err error // POST to unsubscribe from all threads. if r.Method == http.MethodPost { email := r.FormValue("email") if email == "" { - err = errors.New("email address is required to unsubscribe from comment threads") + b.BadRequest(w, r, "email address is required to unsubscribe from comment threads") } else if _, err := mail.ParseAddress(email); err != nil { - err = errors.New("invalid email address") + b.BadRequest(w, r, "invalid email address") } m := comments.LoadMailingList() @@ -294,9 +292,7 @@ func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) { return } - render.Template(w, r, "comments/subscription.gohtml", map[string]error{ - "Error": err, - }) + render.Template(w, r, "comments/subscription.gohtml", nil) } // QuickDeleteHandler allows the admin to quickly delete spam without logging in. diff --git a/core/core.go b/core/core.go index 3324f16..1f96907 100644 --- a/core/core.go +++ b/core/core.go @@ -7,6 +7,10 @@ import ( "path/filepath" "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/controllers/admin" + "github.com/kirsle/blog/core/internal/controllers/authctl" + "github.com/kirsle/blog/core/internal/controllers/contact" + "github.com/kirsle/blog/core/internal/controllers/setup" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/middleware" @@ -16,6 +20,7 @@ import ( "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/models/users" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" "github.com/kirsle/blog/core/internal/sessions" "github.com/kirsle/blog/jsondb" "github.com/kirsle/blog/jsondb/caches" @@ -105,10 +110,10 @@ func (b *Blog) Configure() { func (b *Blog) SetupHTTP() { // Initialize the router. r := mux.NewRouter() - r.HandleFunc("/initial-setup", b.SetupHandler) - b.AuthRoutes(r) - b.AdminRoutes(r) - b.ContactRoutes(r) + setup.Register(r) + authctl.Register(r) + admin.Register(r, b.MustLogin) + contact.Register(r, b.Error) b.BlogRoutes(r) b.CommentRoutes(r) @@ -137,3 +142,9 @@ func (b *Blog) ListenAndServe(address string) { log.Info("Listening on %s", address) http.ListenAndServe(address, b.n) } + +// MustLogin handles errors from the LoginRequired middleware by redirecting +// the user to the login page. +func (b *Blog) MustLogin(w http.ResponseWriter, r *http.Request) { + responses.Redirect(w, "/login?next="+r.URL.Path) +} diff --git a/core/initial-setup.go b/core/initial-setup.go deleted file mode 100644 index d0063f5..0000000 --- a/core/initial-setup.go +++ /dev/null @@ -1,64 +0,0 @@ -package core - -import ( - "net/http" - - "github.com/kirsle/blog/core/internal/forms" - "github.com/kirsle/blog/core/internal/log" - "github.com/kirsle/blog/core/internal/models/settings" - "github.com/kirsle/blog/core/internal/models/users" - "github.com/kirsle/blog/core/internal/render" - "github.com/kirsle/blog/core/internal/responses" - "github.com/kirsle/blog/core/internal/sessions" -) - -// SetupHandler is the initial blog setup route. -func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { - form := &forms.Setup{} - vars := map[string]interface{}{ - "Form": form, - } - - // Reject if we're already set up. - s, _ := settings.Load() - if s.Initialized { - responses.FlashAndRedirect(w, r, "/", "This website has already been configured.") - return - } - - if r.Method == http.MethodPost { - form.ParseForm(r) - err := form.Validate() - if err != nil { - vars["Error"] = err - } else { - // Save the site config. - log.Info("Creating default website config file") - s := settings.Defaults() - s.Save() - - // Re-initialize the cookie store with the new secret key. - sessions.SetSecretKey([]byte(s.Security.SecretKey)) - - log.Info("Creating admin account %s", form.Username) - user := &users.User{ - Username: form.Username, - Password: form.Password, - Admin: true, - Name: "Administrator", - } - err := users.Create(user) - if err != nil { - log.Error("Error: %v", err) - vars["Error"] = err - } - - // All set! - b.Login(w, r, user) - responses.FlashAndRedirect(w, r, "/admin", "Admin user created and logged in.") - return - } - } - - render.Template(w, r, "initial-setup", vars) -} diff --git a/core/internal/controllers/admin/admin.go b/core/internal/controllers/admin/admin.go new file mode 100644 index 0000000..1ade73c --- /dev/null +++ b/core/internal/controllers/admin/admin.go @@ -0,0 +1,27 @@ +package admin + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/render" + "github.com/urfave/negroni" +) + +// Register the initial setup routes. +func Register(r *mux.Router, authErrorFunc http.HandlerFunc) { + adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(true) + adminRouter.HandleFunc("/", indexHandler) + adminRouter.HandleFunc("/settings", settingsHandler) + adminRouter.HandleFunc("/editor", editorHandler) + + r.PathPrefix("/admin").Handler(negroni.New( + negroni.HandlerFunc(auth.LoginRequired(authErrorFunc)), + negroni.Wrap(adminRouter), + )) +} + +func indexHandler(w http.ResponseWriter, r *http.Request) { + render.Template(w, r, "admin/index", nil) +} diff --git a/core/internal/controllers/admin/editor.go b/core/internal/controllers/admin/editor.go new file mode 100644 index 0000000..448bde2 --- /dev/null +++ b/core/internal/controllers/admin/editor.go @@ -0,0 +1,147 @@ +package admin + +import ( + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" +) + +// FileTree holds information about files in the document roots. +type FileTree struct { + UserRoot bool // false = CoreRoot + Files []render.Filepath +} + +func editorHandler(w http.ResponseWriter, r *http.Request) { + // Editing a page? + file := strings.Trim(r.FormValue("file"), "/") + if len(file) > 0 { + var ( + fp string + fromCore = r.FormValue("from") == "core" + saving = r.FormValue("action") == "save" + deleting = r.FormValue("action") == "delete" + body = []byte{} + ) + + // Are they saving? + if saving { + fp = filepath.Join(*render.UserRoot, file) + body = []byte(r.FormValue("body")) + err := ioutil.WriteFile(fp, body, 0644) + if err != nil { + responses.Flash(w, r, "Error saving: %s", err) + } else { + responses.FlashAndRedirect(w, r, "/admin/editor?file="+url.QueryEscape(file), "Page saved successfully!") + return + } + } else if deleting { + fp = filepath.Join(*render.UserRoot, file) + err := os.Remove(fp) + if err != nil { + responses.FlashAndRedirect(w, r, "/admin/editor", "Error deleting: %s", err) + } else { + responses.FlashAndRedirect(w, r, "/admin/editor", "Page deleted!") + return + } + } else { + // Where is the file from? + if fromCore { + fp = filepath.Join(*render.DocumentRoot, file) + } else { + fp = filepath.Join(*render.UserRoot, file) + } + + // Check the file. If not found, check from the core root. + f, err := os.Stat(fp) + if os.IsNotExist(err) { + fp = filepath.Join(*render.DocumentRoot, file) + fromCore = true + f, err = os.Stat(fp) + } + + // If it exists, load it. + if !os.IsNotExist(err) && !f.IsDir() { + body, err = ioutil.ReadFile(fp) + if err != nil { + responses.Flash(w, r, "Error reading %s: %s", fp, err) + } + } + + // Default HTML boilerplate for .gohtml templates. + if len(body) == 0 && strings.HasSuffix(fp, ".gohtml") { + body = []byte("{{ define \"title\" }}Untitled Page{{ end }}\n" + + "{{ define \"content\" }}\n

Untitled Page

\n\n{{ end }}") + } + } + + v := map[string]interface{}{ + "File": file, + "Path": fp, + "Body": string(body), + "FromCore": fromCore, + } + render.Template(w, r, "admin/editor", v) + return + } + + // Otherwise listing the index view. + editorFileList(w, r) +} + +// editorFileList handles the index view of /admin/editor. +func editorFileList(w http.ResponseWriter, r *http.Request) { + // Listing the file tree? + trees := []FileTree{} + for i, root := range []string{*render.UserRoot, *render.DocumentRoot} { + tree := FileTree{ + UserRoot: i == 0, + Files: []render.Filepath{}, + } + + filepath.Walk(root, func(path string, f os.FileInfo, err error) error { + abs, _ := filepath.Abs(path) + rel, _ := filepath.Rel(root, path) + + // Skip hidden files and directories. + if f.IsDir() || rel == "." || strings.HasPrefix(rel, ".private") || strings.HasPrefix(rel, "admin/") { + return nil + } + + // Only text files. + ext := strings.ToLower(filepath.Ext(path)) + okTypes := []string{ + ".html", ".gohtml", ".md", ".markdown", ".js", ".css", ".jsx", + } + ok := false + for _, ft := range okTypes { + if ext == ft { + ok = true + break + } + } + if !ok { + return nil + } + + tree.Files = append(tree.Files, render.Filepath{ + Absolute: abs, + Relative: rel, + Basename: filepath.Base(path), + }) + return nil + }) + + trees = append(trees, tree) + } + v := map[string]interface{}{ + "FileTrees": trees, + } + render.Template(w, r, "admin/filelist", v) +} diff --git a/core/internal/controllers/admin/settings.go b/core/internal/controllers/admin/settings.go new file mode 100644 index 0000000..be66074 --- /dev/null +++ b/core/internal/controllers/admin/settings.go @@ -0,0 +1,72 @@ +package admin + +import ( + "net/http" + "strconv" + + "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" +) + +func settingsHandler(w http.ResponseWriter, r *http.Request) { + // Get the current settings. + settings, _ := settings.Load() + v := map[string]interface{}{ + "s": settings, + } + + if r.Method == http.MethodPost { + redisPort, _ := strconv.Atoi(r.FormValue("redis-port")) + redisDB, _ := strconv.Atoi(r.FormValue("redis-db")) + mailPort, _ := strconv.Atoi(r.FormValue("mail-port")) + form := &forms.Settings{ + Title: r.FormValue("title"), + Description: r.FormValue("description"), + AdminEmail: r.FormValue("admin-email"), + URL: r.FormValue("url"), + RedisEnabled: len(r.FormValue("redis-enabled")) > 0, + RedisHost: r.FormValue("redis-host"), + RedisPort: redisPort, + RedisDB: redisDB, + RedisPrefix: r.FormValue("redis-prefix"), + MailEnabled: len(r.FormValue("mail-enabled")) > 0, + MailSender: r.FormValue("mail-sender"), + MailHost: r.FormValue("mail-host"), + MailPort: mailPort, + MailUsername: r.FormValue("mail-username"), + MailPassword: r.FormValue("mail-password"), + } + + // Copy form values into the settings struct for display, in case of + // any validation errors. + settings.Site.Title = form.Title + settings.Site.Description = form.Description + settings.Site.AdminEmail = form.AdminEmail + settings.Site.URL = form.URL + settings.Redis.Enabled = form.RedisEnabled + settings.Redis.Host = form.RedisHost + settings.Redis.Port = form.RedisPort + settings.Redis.DB = form.RedisDB + settings.Redis.Prefix = form.RedisPrefix + settings.Mail.Enabled = form.MailEnabled + settings.Mail.Sender = form.MailSender + settings.Mail.Host = form.MailHost + settings.Mail.Port = form.MailPort + settings.Mail.Username = form.MailUsername + settings.Mail.Password = form.MailPassword + err := form.Validate() + if err != nil { + v["Error"] = err + } else { + // Save the settings. + settings.Save() + // b.Configure() + + responses.FlashAndReload(w, r, "Settings have been saved!") + return + } + } + render.Template(w, r, "admin/settings", v) +} diff --git a/core/auth.go b/core/internal/controllers/authctl/authctl.go similarity index 75% rename from core/auth.go rename to core/internal/controllers/authctl/authctl.go index ba59417..42da874 100644 --- a/core/auth.go +++ b/core/internal/controllers/authctl/authctl.go @@ -1,4 +1,4 @@ -package core +package authctl import ( "errors" @@ -14,33 +14,14 @@ import ( "github.com/kirsle/blog/core/internal/sessions" ) -// AuthRoutes attaches the auth routes to the app. -func (b *Blog) AuthRoutes(r *mux.Router) { - r.HandleFunc("/login", b.LoginHandler) - r.HandleFunc("/logout", b.LogoutHandler) - r.HandleFunc("/account", b.AccountHandler) +// Register the initial setup routes. +func Register(r *mux.Router) { + r.HandleFunc("/login", loginHandler) + r.HandleFunc("/logout", logoutHandler) + r.HandleFunc("/account", accountHandler) } -// MustLogin handles errors from the LoginRequired middleware by redirecting -// the user to the login page. -func (b *Blog) MustLogin(w http.ResponseWriter, r *http.Request) { - responses.Redirect(w, "/login?next="+r.URL.Path) -} - -// Login logs the browser in as the given user. -func (b *Blog) Login(w http.ResponseWriter, r *http.Request, u *users.User) error { - session, err := sessions.Store.Get(r, "session") // TODO session name - if err != nil { - return err - } - session.Values["logged-in"] = true - session.Values["user-id"] = u.ID - session.Save(r, w) - return nil -} - -// LoginHandler shows and handles the login page. -func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { +func loginHandler(w http.ResponseWriter, r *http.Request) { vars := map[string]interface{}{ "Form": forms.Setup{}, } @@ -70,7 +51,7 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { } else { // Login OK! responses.Flash(w, r, "Login OK!") - b.Login(w, r, user) + auth.Login(w, r, user) // A next URL given? TODO: actually get to work log.Info("Redirect after login to: %s", nextURL) @@ -87,8 +68,7 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) { render.Template(w, r, "login", vars) } -// LogoutHandler logs the user out and redirects to the home page. -func (b *Blog) LogoutHandler(w http.ResponseWriter, r *http.Request) { +func logoutHandler(w http.ResponseWriter, r *http.Request) { session, _ := sessions.Store.Get(r, "session") delete(session.Values, "logged-in") delete(session.Values, "user-id") @@ -96,8 +76,7 @@ func (b *Blog) LogoutHandler(w http.ResponseWriter, r *http.Request) { responses.Redirect(w, "/") } -// AccountHandler shows the account settings page. -func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { +func accountHandler(w http.ResponseWriter, r *http.Request) { if !auth.LoggedIn(r) { responses.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!") return diff --git a/core/contact.go b/core/internal/controllers/contact/contact.go similarity index 77% rename from core/contact.go rename to core/internal/controllers/contact/contact.go index 8c97717..dfcee94 100644 --- a/core/contact.go +++ b/core/internal/controllers/contact/contact.go @@ -1,4 +1,4 @@ -package core +package contact import ( "fmt" @@ -10,14 +10,15 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/mail" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/models/settings" "github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/core/internal/responses" ) -// ContactRoutes attaches the contact URL to the app. -func (b *Blog) ContactRoutes(r *mux.Router) { +// Register attaches the contact URL to the app. +func Register(r *mux.Router, onError func(http.ResponseWriter, *http.Request, string)) { r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) { form := &forms.Contact{} v := map[string]interface{}{ @@ -27,13 +28,13 @@ func (b *Blog) ContactRoutes(r *mux.Router) { // If there is no site admin, show an error. cfg, err := settings.Load() if err != nil { - b.Error(w, r, "Error loading site configuration!") + onError(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!") + onError(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.") + onError(w, r, "This website doesn't have an e-mail gateway configured.") return } @@ -43,7 +44,7 @@ func (b *Blog) ContactRoutes(r *mux.Router) { if err = form.Validate(); err != nil { responses.Flash(w, r, err.Error()) } else { - go b.SendEmail(Email{ + go mail.SendEmail(mail.Email{ To: cfg.Site.AdminEmail, Admin: true, ReplyTo: form.Email, @@ -57,7 +58,7 @@ func (b *Blog) ContactRoutes(r *mux.Router) { }) // Log it to disk, too. - fh, err := os.OpenFile(filepath.Join(b.UserRoot, ".contact.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + fh, err := os.OpenFile(filepath.Join(*render.UserRoot, ".contact.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 { diff --git a/core/internal/controllers/setup/setup.go b/core/internal/controllers/setup/setup.go index 9bca696..2e0a093 100644 --- a/core/internal/controllers/setup/setup.go +++ b/core/internal/controllers/setup/setup.go @@ -1 +1,70 @@ package setup + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/forms" + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" + "github.com/kirsle/blog/core/internal/sessions" +) + +// Register the initial setup routes. +func Register(r *mux.Router) { + r.HandleFunc("/initial-setup", handler) +} + +func handler(w http.ResponseWriter, r *http.Request) { + form := &forms.Setup{} + vars := map[string]interface{}{ + "Form": form, + } + + // Reject if we're already set up. + s, _ := settings.Load() + if s.Initialized { + responses.FlashAndRedirect(w, r, "/", "This website has already been configured.") + return + } + + if r.Method == http.MethodPost { + form.ParseForm(r) + err := form.Validate() + if err != nil { + vars["Error"] = err + } else { + // Save the site config. + log.Info("Creating default website config file") + s := settings.Defaults() + s.Save() + + // Re-initialize the cookie store with the new secret key. + sessions.SetSecretKey([]byte(s.Security.SecretKey)) + + log.Info("Creating admin account %s", form.Username) + user := &users.User{ + Username: form.Username, + Password: form.Password, + Admin: true, + Name: "Administrator", + } + err := users.Create(user) + if err != nil { + log.Error("Error: %v", err) + vars["Error"] = err + } + + // All set! + auth.Login(w, r, user) + responses.FlashAndRedirect(w, r, "/admin", "Admin user created and logged in.") + return + } + } + + render.Template(w, r, "initial-setup", vars) +} diff --git a/core/mail.go b/core/internal/mail/mail.go similarity index 93% rename from core/mail.go rename to core/internal/mail/mail.go index f6f5552..d5090b9 100644 --- a/core/mail.go +++ b/core/internal/mail/mail.go @@ -1,10 +1,10 @@ -package core +package mail import ( "bytes" - "crypto/tls" "fmt" "html/template" + "net/mail" "net/url" "strings" @@ -30,7 +30,7 @@ type Email struct { } // SendEmail sends an email. -func (b *Blog) SendEmail(email Email) { +func SendEmail(email Email) { s, _ := settings.Load() if !s.Mail.Enabled || s.Mail.Host == "" || s.Mail.Port == 0 || s.Mail.Sender == "" { log.Info("Suppressing email: not completely configured") @@ -83,11 +83,6 @@ func (b *Blog) SendEmail(email Email) { m.AddAlternative("text/html", html.String()) d := gomail.NewDialer(s.Mail.Host, s.Mail.Port, s.Mail.Username, s.Mail.Password) - if b.Debug { - d.TLSConfig = &tls.Config{ - InsecureSkipVerify: true, - } - } log.Info("SendEmail: %s (%s) to %s", email.Subject, email.Template, email.To) if err := d.DialAndSend(m); err != nil { @@ -96,7 +91,7 @@ func (b *Blog) SendEmail(email Email) { } // NotifyComment sends notification emails about comments. -func (b *Blog) NotifyComment(c *comments.Comment) { +func NotifyComment(c *comments.Comment) { s, _ := settings.Load() if s.Site.URL == "" { log.Error("Can't send comment notification because the site URL is not configured") @@ -126,7 +121,7 @@ func (b *Blog) NotifyComment(c *comments.Comment) { email.To = config.Site.AdminEmail email.Admin = true log.Info("Mail site admin '%s' about comment notification on '%s'", email.To, c.ThreadID) - b.SendEmail(email) + SendEmail(email) } // Email the subscribers. @@ -143,6 +138,11 @@ func (b *Blog) NotifyComment(c *comments.Comment) { url.QueryEscape(to), ) log.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.ThreadID) - b.SendEmail(email) + SendEmail(email) } } + +// ParseAddress parses an email address. +func ParseAddress(addr string) (*mail.Address, error) { + return mail.ParseAddress(addr) +} diff --git a/core/internal/middleware/auth/auth.go b/core/internal/middleware/auth/auth.go index dad1fd8..74b410d 100644 --- a/core/internal/middleware/auth/auth.go +++ b/core/internal/middleware/auth/auth.go @@ -26,6 +26,18 @@ func CurrentUser(r *http.Request) (*users.User, error) { }, errors.New("not authenticated") } +// Login logs the browser in as the given user. +func Login(w http.ResponseWriter, r *http.Request, u *users.User) error { + session, err := sessions.Store.Get(r, "session") // TODO session name + if err != nil { + return err + } + session.Values["logged-in"] = true + session.Values["user-id"] = u.ID + session.Save(r, w) + return nil +} + // LoggedIn returns whether the current user is logged in to an account. func LoggedIn(r *http.Request) bool { session := sessions.Get(r) diff --git a/root/account.gohtml b/root/account.gohtml index a23df14..be97901 100644 --- a/root/account.gohtml +++ b/root/account.gohtml @@ -2,6 +2,7 @@ {{ define "content" }}

Account Settings

+{{ $form := .Data.Form }} @@ -14,7 +15,7 @@ class="form-control" name="username" id="username" - value="{{ .Form.Username }}" + value="{{ $form.Username }}" placeholder="soandso">
@@ -24,8 +25,8 @@ class="form-control" name="name" id="name" - value="{{ .Form.Name }}" - placeholder="{{ or .Form.Username "Anonymous" }}"> + value="{{ $form.Name }}" + placeholder="{{ or $form.Username "Anonymous" }}">
@@ -34,7 +35,7 @@ class="form-control" name="email" id="email" - value="{{ .Form.Email }}" + value="{{ $form.Email }}" placeholder="name@domain.com">
diff --git a/root/contact.gohtml b/root/contact.gohtml index 66f0d99..13b4dd9 100644 --- a/root/contact.gohtml +++ b/root/contact.gohtml @@ -7,8 +7,6 @@ administrator.

-data={{ .Data }} - {{ $form := .Data.Form }} From c69c14ea09cbd0a225497f3c86a4ac9453b96122 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 10 Feb 2018 15:07:10 -0800 Subject: [PATCH 09/10] Controller breakout for blog and comments --- core/blog.go | 553 ------------------ core/core.go | 10 +- core/errors.go | 85 +-- .../controllers/comments}/comments.go | 75 +-- .../controllers/comments/subscriptions.go | 41 ++ core/internal/controllers/contact/contact.go | 8 +- core/internal/controllers/posts/archive.go | 60 ++ core/internal/controllers/posts/edit.go | 110 ++++ core/internal/controllers/posts/feeds.go | 64 ++ core/internal/controllers/posts/index.go | 125 ++++ core/internal/controllers/posts/posts.go | 212 +++++++ core/internal/controllers/posts/tagged.go | 46 ++ core/internal/responses/responses.go | 8 + core/{ => internal/types}/constants.go | 2 +- core/pages.go | 9 +- 15 files changed, 747 insertions(+), 661 deletions(-) delete mode 100644 core/blog.go rename core/{ => internal/controllers/comments}/comments.go (76%) create mode 100644 core/internal/controllers/comments/subscriptions.go create mode 100644 core/internal/controllers/posts/archive.go create mode 100644 core/internal/controllers/posts/edit.go create mode 100644 core/internal/controllers/posts/feeds.go create mode 100644 core/internal/controllers/posts/index.go create mode 100644 core/internal/controllers/posts/posts.go create mode 100644 core/internal/controllers/posts/tagged.go rename core/{ => internal/types}/constants.go (97%) diff --git a/core/blog.go b/core/blog.go deleted file mode 100644 index 1955e8d..0000000 --- a/core/blog.go +++ /dev/null @@ -1,553 +0,0 @@ -package core - -import ( - "bytes" - "errors" - "fmt" - "html/template" - "net/http" - "sort" - "strconv" - "strings" - "time" - - "github.com/gorilla/feeds" - "github.com/gorilla/mux" - "github.com/kirsle/blog/core/internal/log" - "github.com/kirsle/blog/core/internal/markdown" - "github.com/kirsle/blog/core/internal/middleware/auth" - "github.com/kirsle/blog/core/internal/models/comments" - "github.com/kirsle/blog/core/internal/models/posts" - "github.com/kirsle/blog/core/internal/models/settings" - "github.com/kirsle/blog/core/internal/models/users" - "github.com/kirsle/blog/core/internal/render" - "github.com/kirsle/blog/core/internal/responses" - "github.com/urfave/negroni" -) - -// PostMeta associates a Post with injected metadata. -type PostMeta struct { - Post *posts.Post - Rendered template.HTML - Author *users.User - NumComments int - IndexView bool - Snipped bool -} - -// Archive holds data for a piece of the blog archive. -type Archive struct { - Label string - Date time.Time - Posts []posts.Post -} - -// BlogRoutes attaches the blog routes to the app. -func (b *Blog) BlogRoutes(r *mux.Router) { - render.Funcs["RenderIndex"] = b.RenderIndex - render.Funcs["RenderPost"] = b.RenderPost - render.Funcs["RenderTags"] = b.RenderTags - - // Public routes - r.HandleFunc("/blog", b.IndexHandler) - r.HandleFunc("/blog.rss", b.RSSHandler) - r.HandleFunc("/blog.atom", b.RSSHandler) - r.HandleFunc("/archive", b.BlogArchive) - r.HandleFunc("/tagged", b.Tagged) - r.HandleFunc("/tagged/{tag}", b.Tagged) - r.HandleFunc("/blog/category/{tag}", func(w http.ResponseWriter, r *http.Request) { - params := mux.Vars(r) - tag, ok := params["tag"] - if !ok { - b.NotFound(w, r, "Not Found") - return - } - responses.Redirect(w, "/tagged/"+tag) - }) - r.HandleFunc("/blog/entry/{fragment}", func(w http.ResponseWriter, r *http.Request) { - params := mux.Vars(r) - fragment, ok := params["fragment"] - if !ok { - b.NotFound(w, r, "Not Found") - return - } - responses.Redirect(w, "/"+fragment) - }) - - // Login-required routers. - loginRouter := mux.NewRouter() - loginRouter.HandleFunc("/blog/edit", b.EditBlog) - loginRouter.HandleFunc("/blog/delete", b.DeletePost) - loginRouter.HandleFunc("/blog/drafts", b.Drafts) - loginRouter.HandleFunc("/blog/private", b.PrivatePosts) - r.PathPrefix("/blog").Handler( - negroni.New( - negroni.HandlerFunc(auth.LoginRequired(b.MustLogin)), - negroni.Wrap(loginRouter), - ), - ) -} - -// RSSHandler renders an RSS feed from the blog. -func (b *Blog) RSSHandler(w http.ResponseWriter, r *http.Request) { - config, _ := settings.Load() - admin, err := users.Load(1) - if err != nil { - b.Error(w, r, "Blog isn't ready yet.") - return - } - - feed := &feeds.Feed{ - Title: config.Site.Title, - Link: &feeds.Link{Href: config.Site.URL}, - Description: config.Site.Description, - Author: &feeds.Author{ - Name: admin.Name, - Email: admin.Email, - }, - Created: time.Now(), - } - - feed.Items = []*feeds.Item{} - for i, p := range b.RecentPosts(r, "", "") { - post, _ := posts.Load(p.ID) - var suffix string - if strings.Contains(post.Body, "") { - post.Body = strings.Split(post.Body, "")[0] - suffix = "..." - } - - feed.Items = append(feed.Items, &feeds.Item{ - Title: p.Title, - Link: &feeds.Link{Href: config.Site.URL + p.Fragment}, - Description: post.Body + suffix, - Created: p.Created, - }) - if i >= 5 { - break - } - } - - // What format to encode it in? - if strings.Contains(r.URL.Path, ".atom") { - atom, _ := feed.ToAtom() - w.Header().Set("Content-Type", "application/atom+xml") - w.Write([]byte(atom)) - } else { - rss, _ := feed.ToRss() - w.Header().Set("Content-Type", "application/rss+xml") - w.Write([]byte(rss)) - } -} - -// IndexHandler renders the main index page of the blog. -func (b *Blog) IndexHandler(w http.ResponseWriter, r *http.Request) { - b.CommonIndexHandler(w, r, "", "") -} - -// Tagged lets you browse blog posts by category. -func (b *Blog) Tagged(w http.ResponseWriter, r *http.Request) { - params := mux.Vars(r) - tag, ok := params["tag"] - if !ok { - // They're listing all the tags. - render.Template(w, r, "blog/tags.gohtml", nil) - return - } - - b.CommonIndexHandler(w, r, tag, "") -} - -// Drafts renders an index view of only draft posts. Login required. -func (b *Blog) Drafts(w http.ResponseWriter, r *http.Request) { - b.CommonIndexHandler(w, r, "", DRAFT) -} - -// PrivatePosts renders an index view of only private posts. Login required. -func (b *Blog) PrivatePosts(w http.ResponseWriter, r *http.Request) { - b.CommonIndexHandler(w, r, "", PRIVATE) -} - -// CommonIndexHandler handles common logic for blog index views. -func (b *Blog) CommonIndexHandler(w http.ResponseWriter, r *http.Request, tag, privacy string) { - // Page title. - var title string - if privacy == DRAFT { - title = "Draft Posts" - } else if privacy == PRIVATE { - title = "Private Posts" - } else if tag != "" { - title = "Tagged as: " + tag - } else { - title = "Blog" - } - - render.Template(w, r, "blog/index", map[string]interface{}{ - "Title": title, - "Tag": tag, - "Privacy": privacy, - }) -} - -// RecentPosts gets and filters the blog entries and orders them by most recent. -func (b *Blog) RecentPosts(r *http.Request, tag, privacy string) []posts.Post { - // Get the blog index. - idx, _ := posts.GetIndex() - - // The set of blog posts to show. - var pool []posts.Post - for _, post := range idx.Posts { - // Limiting by a specific privacy setting? (drafts or private only) - if privacy != "" { - switch privacy { - case DRAFT: - if post.Privacy != DRAFT { - continue - } - case PRIVATE: - if post.Privacy != PRIVATE && post.Privacy != UNLISTED { - continue - } - } - } else { - // Exclude certain posts in generic index views. - if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !auth.LoggedIn(r) { - continue - } else if post.Privacy == DRAFT { - continue - } - } - - // Limit by tag? - if tag != "" { - var tagMatch bool - if tag != "" { - for _, check := range post.Tags { - if check == tag { - tagMatch = true - break - } - } - } - - if !tagMatch { - continue - } - } - - pool = append(pool, post) - } - - sort.Sort(sort.Reverse(posts.ByUpdated(pool))) - return pool -} - -// RenderIndex renders and returns the blog index partial. -func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML { - // Get the recent blog entries, filtered by the tag/privacy settings. - pool := b.RecentPosts(r, tag, privacy) - if len(pool) == 0 { - return template.HTML("No blog posts were found.") - } - - // Query parameters. - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - if page <= 0 { - page = 1 - } - perPage := 5 // TODO: configurable - offset := (page - 1) * perPage - stop := offset + perPage - - // Handle pagination. - var previousPage, nextPage int - if page > 1 { - previousPage = page - 1 - } else { - previousPage = 0 - } - if offset+perPage < len(pool) { - nextPage = page + 1 - } else { - nextPage = 0 - } - - var view []PostMeta - for i := offset; i < stop; i++ { - if i >= len(pool) { - continue - } - post, err := posts.Load(pool[i].ID) - if err != nil { - log.Error("couldn't load full post data for ID %d (found in index.json)", pool[i].ID) - continue - } - - // Look up the author's information. - author, err := users.LoadReadonly(post.AuthorID) - if err != nil { - log.Error("Failed to look up post author ID %d (post %d): %v", post.AuthorID, post.ID, err) - author = users.DeletedUser() - } - - // Count the comments on this post. - var numComments int - if thread, err := comments.Load(fmt.Sprintf("post-%d", post.ID)); err == nil { - numComments = len(thread.Comments) - } - - view = append(view, PostMeta{ - Post: post, - Author: author, - NumComments: numComments, - }) - } - - // Render the blog index partial. - var output bytes.Buffer - v := map[string]interface{}{ - "PreviousPage": previousPage, - "NextPage": nextPage, - "View": view, - } - render.Template(&output, r, "blog/index.partial", v) - - 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 := map[string]interface{}{ - "IndexView": indexView, - "Tags": tags, - } - render.Template(&output, r, "blog/tags.partial", v) - - 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() - if err != nil { - b.BadRequest(w, r, "Error getting blog index") - return - } - - // Group posts by calendar month. - var months []string - byMonth := map[string]*Archive{} - for _, post := range idx.Posts { - // Exclude certain posts - if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !auth.LoggedIn(r) { - continue - } else if post.Privacy == DRAFT { - continue - } - - label := post.Created.Format("2006-01") - if _, ok := byMonth[label]; !ok { - months = append(months, label) - byMonth[label] = &Archive{ - Label: label, - Date: time.Date(post.Created.Year(), post.Created.Month(), post.Created.Day(), 0, 0, 0, 0, time.UTC), - Posts: []posts.Post{}, - } - } - byMonth[label].Posts = append(byMonth[label].Posts, post) - } - - // Sort the months. - sort.Sort(sort.Reverse(sort.StringSlice(months))) - - // Prepare the response. - result := []*Archive{} - for _, label := range months { - sort.Sort(sort.Reverse(posts.ByUpdated(byMonth[label].Posts))) - result = append(result, byMonth[label]) - } - - v := map[string]interface{}{ - "Archive": result, - } - render.Template(w, r, "blog/archive", v) -} - -// viewPost is the underlying implementation of the handler to view a blog -// post, so that it can be called from non-http.HandlerFunc contexts. -// Specifically, from the catch-all page handler to allow blog URL fragments -// to map to their post. -func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string) error { - post, err := posts.LoadFragment(fragment) - if err != nil { - return err - } - - // Handle post privacy. - if post.Privacy == PRIVATE || post.Privacy == DRAFT { - if !auth.LoggedIn(r) { - b.NotFound(w, r, "That post is not public.") - return nil - } - } - - v := map[string]interface{}{ - "Post": post, - } - render.Template(w, r, "blog/entry", v) - - return nil -} - -// RenderPost renders a blog post as a partial template and returns the HTML. -// If indexView is true, the blog headers will be hyperlinked to the dedicated -// entry view page. -func (b *Blog) RenderPost(r *http.Request, p *posts.Post, indexView bool, numComments int) template.HTML { - // Look up the author's information. - author, err := users.LoadReadonly(p.AuthorID) - if err != nil { - log.Error("Failed to look up post author ID %d (post %d): %v", p.AuthorID, p.ID, err) - author = users.DeletedUser() - } - - // "Read More" snippet for index views. - var snipped bool - if indexView { - if strings.Contains(p.Body, "") { - 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) { - rendered = template.HTML(markdown.RenderTrustedMarkdown(p.Body)) - } else { - rendered = template.HTML(p.Body) - } - - meta := map[string]interface{}{ - "Post": p, - "Rendered": rendered, - "Author": author, - "IndexView": indexView, - "Snipped": snipped, - "NumComments": numComments, - } - output := bytes.Buffer{} - err = render.Template(&output, r, "blog/entry.partial", meta) - if err != nil { - return template.HTML(fmt.Sprintf("[template error in blog/entry.partial: %s]", err.Error())) - } - - return template.HTML(output.String()) -} - -// EditBlog is the blog writing and editing page. -func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { - v := map[string]interface{}{ - "preview": "", - } - var post *posts.Post - - // Are we editing an existing post? - if idStr := r.FormValue("id"); idStr != "" { - id, err := strconv.Atoi(idStr) - if err == nil { - post, err = posts.Load(id) - if err != nil { - v["Error"] = errors.New("that post ID was not found") - post = posts.New() - } - } - } else { - post = posts.New() - } - - if r.Method == http.MethodPost { - // Parse from form values. - post.ParseForm(r) - - // Previewing, or submitting? - switch r.FormValue("submit") { - case "preview": - if post.ContentType == string(MARKDOWN) { - v["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) - } else { - v["preview"] = template.HTML(post.Body) - } - case "post": - if err := post.Validate(); err != nil { - v["Error"] = err - } else { - author, _ := auth.CurrentUser(r) - post.AuthorID = author.ID - - post.Updated = time.Now().UTC() - err = post.Save() - if err != nil { - v["Error"] = err - } else { - responses.Flash(w, r, "Post created!") - responses.Redirect(w, "/"+post.Fragment) - } - } - } - } - - v["post"] = post - render.Template(w, r, "blog/edit", v) -} - -// DeletePost to delete a blog entry. -func (b *Blog) DeletePost(w http.ResponseWriter, r *http.Request) { - var post *posts.Post - v := map[string]interface{}{ - "Post": nil, - } - - var idStr string - if r.Method == http.MethodPost { - idStr = r.FormValue("id") - } else { - idStr = r.URL.Query().Get("id") - } - if idStr == "" { - responses.FlashAndRedirect(w, r, "/admin", "No post ID given for deletion!") - return - } - - // Convert the post ID to an int. - id, err := strconv.Atoi(idStr) - if err == nil { - post, err = posts.Load(id) - if err != nil { - responses.FlashAndRedirect(w, r, "/admin", "That post ID was not found.") - return - } - } - - if r.Method == http.MethodPost { - post.Delete() - responses.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!") - return - } - - v["Post"] = post - render.Template(w, r, "blog/delete", v) -} diff --git a/core/core.go b/core/core.go index 1f96907..d8d78cb 100644 --- a/core/core.go +++ b/core/core.go @@ -9,7 +9,9 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/controllers/admin" "github.com/kirsle/blog/core/internal/controllers/authctl" + commentctl "github.com/kirsle/blog/core/internal/controllers/comments" "github.com/kirsle/blog/core/internal/controllers/contact" + postctl "github.com/kirsle/blog/core/internal/controllers/posts" "github.com/kirsle/blog/core/internal/controllers/setup" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" @@ -113,9 +115,9 @@ func (b *Blog) SetupHTTP() { setup.Register(r) authctl.Register(r) admin.Register(r, b.MustLogin) - contact.Register(r, b.Error) - b.BlogRoutes(r) - b.CommentRoutes(r) + contact.Register(r) + postctl.Register(r, b.MustLogin) + commentctl.Register(r) // GitHub Flavored Markdown CSS. r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) @@ -127,7 +129,7 @@ func (b *Blog) SetupHTTP() { negroni.NewRecovery(), negroni.NewLogger(), negroni.HandlerFunc(sessions.Middleware), - negroni.HandlerFunc(middleware.CSRF(b.Forbidden)), + negroni.HandlerFunc(middleware.CSRF(responses.Forbidden)), negroni.HandlerFunc(auth.Middleware), ) n.UseHandler(r) diff --git a/core/errors.go b/core/errors.go index 8984360..52175cf 100644 --- a/core/errors.go +++ b/core/errors.go @@ -5,56 +5,57 @@ import ( "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" ) -// NotFound sends a 404 response. -func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message string) { - if message == "" { - message = "The page you were looking for was not found." +// registerErrors loads the error handlers into the responses subpackage. +func (b *Blog) registerErrors() { + responses.NotFound = func(w http.ResponseWriter, r *http.Request, message string) { + if message == "" { + message = "The page you were looking for was not found." + } + + w.WriteHeader(http.StatusNotFound) + err := render.Template(w, r, ".errors/404", map[string]string{ + "Message": message, + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for NotFound()")) + } } - w.WriteHeader(http.StatusNotFound) - err := render.Template(w, r, ".errors/404", map[string]string{ - "Message": message, - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for NotFound()")) + responses.Forbidden = func(w http.ResponseWriter, r *http.Request, message string) { + w.WriteHeader(http.StatusForbidden) + err := render.Template(w, r, ".errors/403", map[string]string{ + "Message": message, + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for Forbidden()")) + } } -} -// Forbidden sends an HTTP 403 Forbidden response. -func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message string) { - w.WriteHeader(http.StatusForbidden) - err := render.Template(w, r, ".errors/403", map[string]string{ - "Message": message, - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for Forbidden()")) + responses.Error = func(w http.ResponseWriter, r *http.Request, message string) { + w.WriteHeader(http.StatusInternalServerError) + err := render.Template(w, r, ".errors/500", map[string]string{ + "Message": message, + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for Error()")) + } } -} -// Error sends an HTTP 500 Internal Server Error response. -func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message string) { - w.WriteHeader(http.StatusInternalServerError) - err := render.Template(w, r, ".errors/500", map[string]string{ - "Message": message, - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for Error()")) + responses.BadRequest = func(w http.ResponseWriter, r *http.Request, message string) { + w.WriteHeader(http.StatusBadRequest) + err := render.Template(w, r, ".errors/400", map[string]string{ + "Message": message, + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for BadRequest()")) + } } -} -// BadRequest sends an HTTP 400 Bad Request. -func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message string) { - w.WriteHeader(http.StatusBadRequest) - err := render.Template(w, r, ".errors/400", map[string]string{ - "Message": message, - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for BadRequest()")) - } } diff --git a/core/comments.go b/core/internal/controllers/comments/comments.go similarity index 76% rename from core/comments.go rename to core/internal/controllers/comments/comments.go index ba5164a..8ee594a 100644 --- a/core/comments.go +++ b/core/internal/controllers/comments/comments.go @@ -1,4 +1,4 @@ -package core +package comments import ( "bytes" @@ -19,13 +19,16 @@ import ( "github.com/kirsle/blog/core/internal/sessions" ) -// CommentRoutes attaches the comment routes to the app. -func (b *Blog) CommentRoutes(r *mux.Router) { - render.Funcs["RenderComments"] = b.RenderComments +var badRequest func(http.ResponseWriter, *http.Request, string) - r.HandleFunc("/comments", b.CommentHandler) - r.HandleFunc("/comments/subscription", b.SubscriptionHandler) - r.HandleFunc("/comments/quick-delete", b.QuickDeleteHandler) +// Register the comment routes to the app. +func Register(r *mux.Router) { + badRequest = responses.BadRequest + render.Funcs["RenderComments"] = RenderComments + + r.HandleFunc("/comments", commentHandler) + r.HandleFunc("/comments/subscription", subscriptionHandler) + r.HandleFunc("/comments/quick-delete", quickDeleteHandler) } // CommentMeta is the template variables for comment threads. @@ -40,7 +43,7 @@ type CommentMeta struct { } // RenderComments renders a comment form partial and returns the HTML. -func (b *Blog) RenderComments(r *http.Request, subject string, ids ...string) template.HTML { +func RenderComments(r *http.Request, subject string, ids ...string) template.HTML { id := strings.Join(ids, "-") session := sessions.Get(r) url := r.URL.Path @@ -141,14 +144,13 @@ func (b *Blog) RenderComments(r *http.Request, subject string, ids ...string) te return template.HTML(output.String()) } -// CommentHandler handles the /comments URI for previewing and posting. -func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { +func commentHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - b.BadRequest(w, r, "That method is not allowed.") + badRequest(w, r, "That method is not allowed.") return } currentUser, _ := auth.CurrentUser(r) - editToken := b.GetEditToken(w, r) + editToken := getEditToken(w, r) submit := r.FormValue("submit") // Load the comment data from the form. @@ -207,14 +209,14 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { // Previewing, deleting, or posting? switch submit { - case ActionPreview, ActionDelete: + case "preview", "delete": if !c.Editing && currentUser.IsAuthenticated { c.Name = currentUser.Name c.Email = currentUser.Email c.LoadAvatar() } c.HTML = template.HTML(markdown.RenderMarkdown(c.Body)) - case ActionPost: + case "post": if err := c.Validate(); err != nil { v["Error"] = err } else { @@ -258,55 +260,22 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { v["Thread"] = t v["Comment"] = c v["Editing"] = c.Editing - v["Deleting"] = submit == ActionDelete + v["Deleting"] = submit == "delete" render.Template(w, r, "comments/index.gohtml", v) } -// SubscriptionHandler to opt out of subscriptions. -func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) { - // POST to unsubscribe from all threads. - if r.Method == http.MethodPost { - email := r.FormValue("email") - if email == "" { - b.BadRequest(w, r, "email address is required to unsubscribe from comment threads") - } else if _, err := mail.ParseAddress(email); err != nil { - b.BadRequest(w, r, "invalid email address") - } - - m := comments.LoadMailingList() - m.UnsubscribeAll(email) - responses.FlashAndRedirect(w, r, "/comments/subscription", - "You have been unsubscribed from all mailing lists.", - ) - return - } - - // GET to unsubscribe from a single thread. - thread := r.URL.Query().Get("t") - email := r.URL.Query().Get("e") - if thread != "" && email != "" { - m := comments.LoadMailingList() - m.Unsubscribe(thread, email) - responses.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.") - return - } - - render.Template(w, r, "comments/subscription.gohtml", nil) -} - -// QuickDeleteHandler allows the admin to quickly delete spam without logging in. -func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) { +func quickDeleteHandler(w http.ResponseWriter, r *http.Request) { thread := r.URL.Query().Get("t") token := r.URL.Query().Get("d") if thread == "" || token == "" { - b.BadRequest(w, r, "Bad Request") + badRequest(w, r, "Bad Request") return } t, err := comments.Load(thread) if err != nil { - b.BadRequest(w, r, "Comment thread does not exist.") + badRequest(w, r, "Comment thread does not exist.") return } @@ -317,9 +286,9 @@ func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) { responses.FlashAndRedirect(w, r, "/", "Comment deleted!") } -// GetEditToken gets or generates an edit token from the user's session, which +// getEditToken gets or generates an edit token from the user's session, which // allows a user to edit their comment for a short while after they post it. -func (b *Blog) GetEditToken(w http.ResponseWriter, r *http.Request) string { +func getEditToken(w http.ResponseWriter, r *http.Request) string { session := sessions.Get(r) if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 { return token diff --git a/core/internal/controllers/comments/subscriptions.go b/core/internal/controllers/comments/subscriptions.go new file mode 100644 index 0000000..58e217c --- /dev/null +++ b/core/internal/controllers/comments/subscriptions.go @@ -0,0 +1,41 @@ +package comments + +import ( + "net/http" + "net/mail" + + "github.com/kirsle/blog/core/internal/models/comments" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" +) + +func subscriptionHandler(w http.ResponseWriter, r *http.Request) { + // POST to unsubscribe from all threads. + if r.Method == http.MethodPost { + email := r.FormValue("email") + if email == "" { + badRequest(w, r, "email address is required to unsubscribe from comment threads") + } else if _, err := mail.ParseAddress(email); err != nil { + badRequest(w, r, "invalid email address") + } + + m := comments.LoadMailingList() + m.UnsubscribeAll(email) + responses.FlashAndRedirect(w, r, "/comments/subscription", + "You have been unsubscribed from all mailing lists.", + ) + return + } + + // GET to unsubscribe from a single thread. + thread := r.URL.Query().Get("t") + email := r.URL.Query().Get("e") + if thread != "" && email != "" { + m := comments.LoadMailingList() + m.Unsubscribe(thread, email) + responses.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.") + return + } + + render.Template(w, r, "comments/subscription.gohtml", nil) +} diff --git a/core/internal/controllers/contact/contact.go b/core/internal/controllers/contact/contact.go index dfcee94..7d3a444 100644 --- a/core/internal/controllers/contact/contact.go +++ b/core/internal/controllers/contact/contact.go @@ -18,7 +18,7 @@ import ( ) // Register attaches the contact URL to the app. -func Register(r *mux.Router, onError func(http.ResponseWriter, *http.Request, string)) { +func Register(r *mux.Router) { r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) { form := &forms.Contact{} v := map[string]interface{}{ @@ -28,13 +28,13 @@ func Register(r *mux.Router, onError func(http.ResponseWriter, *http.Request, st // If there is no site admin, show an error. cfg, err := settings.Load() if err != nil { - onError(w, r, "Error loading site configuration!") + responses.Error(w, r, "Error loading site configuration!") return } else if cfg.Site.AdminEmail == "" { - onError(w, r, "There is no admin email configured for this website!") + responses.Error(w, r, "There is no admin email configured for this website!") return } else if !cfg.Mail.Enabled { - onError(w, r, "This website doesn't have an e-mail gateway configured.") + responses.Error(w, r, "This website doesn't have an e-mail gateway configured.") return } diff --git a/core/internal/controllers/posts/archive.go b/core/internal/controllers/posts/archive.go new file mode 100644 index 0000000..4362723 --- /dev/null +++ b/core/internal/controllers/posts/archive.go @@ -0,0 +1,60 @@ +package postctl + +import ( + "net/http" + "sort" + "time" + + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" + "github.com/kirsle/blog/core/internal/types" +) + +// archiveHandler summarizes all blog entries in an archive view. +func archiveHandler(w http.ResponseWriter, r *http.Request) { + idx, err := posts.GetIndex() + if err != nil { + responses.BadRequest(w, r, "Error getting blog index") + return + } + + // Group posts by calendar month. + var months []string + byMonth := map[string]*Archive{} + for _, post := range idx.Posts { + // Exclude certain posts + if (post.Privacy == types.PRIVATE || post.Privacy == types.UNLISTED) && !auth.LoggedIn(r) { + continue + } else if post.Privacy == types.DRAFT { + continue + } + + label := post.Created.Format("2006-01") + if _, ok := byMonth[label]; !ok { + months = append(months, label) + byMonth[label] = &Archive{ + Label: label, + Date: time.Date(post.Created.Year(), post.Created.Month(), post.Created.Day(), 0, 0, 0, 0, time.UTC), + Posts: []posts.Post{}, + } + } + byMonth[label].Posts = append(byMonth[label].Posts, post) + } + + // Sort the months. + sort.Sort(sort.Reverse(sort.StringSlice(months))) + + // Prepare the response. + result := []*Archive{} + for _, label := range months { + sort.Sort(sort.Reverse(posts.ByUpdated(byMonth[label].Posts))) + result = append(result, byMonth[label]) + } + + v := map[string]interface{}{ + "Archive": result, + } + render.Template(w, r, "blog/archive", v) +} diff --git a/core/internal/controllers/posts/edit.go b/core/internal/controllers/posts/edit.go new file mode 100644 index 0000000..8ca1b1f --- /dev/null +++ b/core/internal/controllers/posts/edit.go @@ -0,0 +1,110 @@ +package postctl + +import ( + "errors" + "html/template" + "net/http" + "strconv" + "time" + + "github.com/kirsle/blog/core/internal/markdown" + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" + "github.com/kirsle/blog/core/internal/types" +) + +// editHandler is the blog writing and editing page. +func editHandler(w http.ResponseWriter, r *http.Request) { + v := map[string]interface{}{ + "preview": "", + } + var post *posts.Post + + // Are we editing an existing post? + if idStr := r.FormValue("id"); idStr != "" { + id, err := strconv.Atoi(idStr) + if err == nil { + post, err = posts.Load(id) + if err != nil { + v["Error"] = errors.New("that post ID was not found") + post = posts.New() + } + } + } else { + post = posts.New() + } + + if r.Method == http.MethodPost { + // Parse from form values. + post.ParseForm(r) + + // Previewing, or submitting? + switch r.FormValue("submit") { + case "preview": + if post.ContentType == string(types.MARKDOWN) { + v["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) + } else { + v["preview"] = template.HTML(post.Body) + } + case "post": + if err := post.Validate(); err != nil { + v["Error"] = err + } else { + author, _ := auth.CurrentUser(r) + post.AuthorID = author.ID + + post.Updated = time.Now().UTC() + err = post.Save() + if err != nil { + v["Error"] = err + } else { + responses.Flash(w, r, "Post created!") + responses.Redirect(w, "/"+post.Fragment) + } + } + } + } + + v["post"] = post + render.Template(w, r, "blog/edit", v) +} + +// deleteHandler to delete a blog entry. +func deleteHandler(w http.ResponseWriter, r *http.Request) { + var post *posts.Post + v := map[string]interface{}{ + "Post": nil, + } + + var idStr string + if r.Method == http.MethodPost { + idStr = r.FormValue("id") + } else { + idStr = r.URL.Query().Get("id") + } + if idStr == "" { + responses.FlashAndRedirect(w, r, "/admin", "No post ID given for deletion!") + return + } + + // Convert the post ID to an int. + id, err := strconv.Atoi(idStr) + if err == nil { + post, err = posts.Load(id) + if err != nil { + responses.FlashAndRedirect(w, r, "/admin", "That post ID was not found.") + return + } + } + + if r.Method == http.MethodPost { + post.Delete() + responses.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!") + return + } + + v["Post"] = post + render.Template(w, r, "blog/delete", v) +} diff --git a/core/internal/controllers/posts/feeds.go b/core/internal/controllers/posts/feeds.go new file mode 100644 index 0000000..1c9990c --- /dev/null +++ b/core/internal/controllers/posts/feeds.go @@ -0,0 +1,64 @@ +package postctl + +import ( + "net/http" + "strings" + "time" + + "github.com/gorilla/feeds" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/responses" +) + +func feedHandler(w http.ResponseWriter, r *http.Request) { + config, _ := settings.Load() + admin, err := users.Load(1) + if err != nil { + responses.Error(w, r, "Blog isn't ready yet.") + return + } + + feed := &feeds.Feed{ + Title: config.Site.Title, + Link: &feeds.Link{Href: config.Site.URL}, + Description: config.Site.Description, + Author: &feeds.Author{ + Name: admin.Name, + Email: admin.Email, + }, + Created: time.Now(), + } + + feed.Items = []*feeds.Item{} + for i, p := range RecentPosts(r, "", "") { + post, _ := posts.Load(p.ID) + var suffix string + if strings.Contains(post.Body, "") { + post.Body = strings.Split(post.Body, "")[0] + suffix = "..." + } + + feed.Items = append(feed.Items, &feeds.Item{ + Title: p.Title, + Link: &feeds.Link{Href: config.Site.URL + p.Fragment}, + Description: post.Body + suffix, + Created: p.Created, + }) + if i >= 5 { + break + } + } + + // What format to encode it in? + if strings.Contains(r.URL.Path, ".atom") { + atom, _ := feed.ToAtom() + w.Header().Set("Content-Type", "application/atom+xml") + w.Write([]byte(atom)) + } else { + rss, _ := feed.ToRss() + w.Header().Set("Content-Type", "application/rss+xml") + w.Write([]byte(rss)) + } +} diff --git a/core/internal/controllers/posts/index.go b/core/internal/controllers/posts/index.go new file mode 100644 index 0000000..2c0bc64 --- /dev/null +++ b/core/internal/controllers/posts/index.go @@ -0,0 +1,125 @@ +package postctl + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "strconv" + + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/models/comments" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/types" +) + +// partialIndex renders and returns the blog index partial. +func partialIndex(r *http.Request, tag, privacy string) template.HTML { + // Get the recent blog entries, filtered by the tag/privacy settings. + pool := RecentPosts(r, tag, privacy) + if len(pool) == 0 { + return template.HTML("No blog posts were found.") + } + + // Query parameters. + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page <= 0 { + page = 1 + } + perPage := 5 // TODO: configurable + offset := (page - 1) * perPage + stop := offset + perPage + + // Handle pagination. + var previousPage, nextPage int + if page > 1 { + previousPage = page - 1 + } else { + previousPage = 0 + } + if offset+perPage < len(pool) { + nextPage = page + 1 + } else { + nextPage = 0 + } + + var view []PostMeta + for i := offset; i < stop; i++ { + if i >= len(pool) { + continue + } + post, err := posts.Load(pool[i].ID) + if err != nil { + log.Error("couldn't load full post data for ID %d (found in index.json)", pool[i].ID) + continue + } + + // Look up the author's information. + author, err := users.LoadReadonly(post.AuthorID) + if err != nil { + log.Error("Failed to look up post author ID %d (post %d): %v", post.AuthorID, post.ID, err) + author = users.DeletedUser() + } + + // Count the comments on this post. + var numComments int + if thread, err := comments.Load(fmt.Sprintf("post-%d", post.ID)); err == nil { + numComments = len(thread.Comments) + } + + view = append(view, PostMeta{ + Post: post, + Author: author, + NumComments: numComments, + }) + } + + // Render the blog index partial. + var output bytes.Buffer + v := map[string]interface{}{ + "PreviousPage": previousPage, + "NextPage": nextPage, + "View": view, + } + render.Template(&output, r, "blog/index.partial", v) + + return template.HTML(output.String()) +} + +// indexHandler renders the main index page of the blog. +func indexHandler(w http.ResponseWriter, r *http.Request) { + commonIndexHandler(w, r, "", "") +} + +// drafts renders an index view of only draft posts. Login required. +func drafts(w http.ResponseWriter, r *http.Request) { + commonIndexHandler(w, r, "", types.DRAFT) +} + +// privatePosts renders an index view of only private posts. Login required. +func privatePosts(w http.ResponseWriter, r *http.Request) { + commonIndexHandler(w, r, "", types.PRIVATE) +} + +// commonIndexHandler handles common logic for blog index views. +func commonIndexHandler(w http.ResponseWriter, r *http.Request, tag, privacy string) { + // Page title. + var title string + if privacy == types.DRAFT { + title = "Draft Posts" + } else if privacy == types.PRIVATE { + title = "Private Posts" + } else if tag != "" { + title = "Tagged as: " + tag + } else { + title = "Blog" + } + + render.Template(w, r, "blog/index", map[string]interface{}{ + "Title": title, + "Tag": tag, + "Privacy": privacy, + }) +} diff --git a/core/internal/controllers/posts/posts.go b/core/internal/controllers/posts/posts.go new file mode 100644 index 0000000..5fd0106 --- /dev/null +++ b/core/internal/controllers/posts/posts.go @@ -0,0 +1,212 @@ +package postctl + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "sort" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/markdown" + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" + "github.com/kirsle/blog/core/internal/types" + "github.com/urfave/negroni" +) + +// PostMeta associates a Post with injected metadata. +type PostMeta struct { + Post *posts.Post + Rendered template.HTML + Author *users.User + NumComments int + IndexView bool + Snipped bool +} + +// Archive holds data for a piece of the blog archive. +type Archive struct { + Label string + Date time.Time + Posts []posts.Post +} + +// Register the blog routes to the app. +func Register(r *mux.Router, loginError http.HandlerFunc) { + render.Funcs["RenderIndex"] = partialIndex + render.Funcs["RenderPost"] = partialPost + render.Funcs["RenderTags"] = partialTags + + // Public routes + r.HandleFunc("/blog", indexHandler) + r.HandleFunc("/blog.rss", feedHandler) + r.HandleFunc("/blog.atom", feedHandler) + r.HandleFunc("/archive", archiveHandler) + r.HandleFunc("/tagged", taggedHandler) + r.HandleFunc("/tagged/{tag}", taggedHandler) + r.HandleFunc("/blog/category/{tag}", func(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + tag, ok := params["tag"] + if !ok { + responses.NotFound(w, r, "Not Found") + return + } + responses.Redirect(w, "/tagged/"+tag) + }) + r.HandleFunc("/blog/entry/{fragment}", func(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + fragment, ok := params["fragment"] + if !ok { + responses.NotFound(w, r, "Not Found") + return + } + responses.Redirect(w, "/"+fragment) + }) + + // Login-required routers. + loginRouter := mux.NewRouter() + loginRouter.HandleFunc("/blog/edit", editHandler) + loginRouter.HandleFunc("/blog/delete", deleteHandler) + loginRouter.HandleFunc("/blog/drafts", drafts) + loginRouter.HandleFunc("/blog/private", privatePosts) + r.PathPrefix("/blog").Handler( + negroni.New( + negroni.HandlerFunc(auth.LoginRequired(loginError)), + negroni.Wrap(loginRouter), + ), + ) +} + +// RecentPosts gets and filters the blog entries and orders them by most recent. +func RecentPosts(r *http.Request, tag, privacy string) []posts.Post { + // Get the blog index. + idx, _ := posts.GetIndex() + + // The set of blog posts to show. + var pool []posts.Post + for _, post := range idx.Posts { + // Limiting by a specific privacy setting? (drafts or private only) + if privacy != "" { + switch privacy { + case types.DRAFT: + if post.Privacy != types.DRAFT { + continue + } + case types.PRIVATE: + if post.Privacy != types.PRIVATE && post.Privacy != types.UNLISTED { + continue + } + } + } else { + // Exclude certain posts in generic index views. + if (post.Privacy == types.PRIVATE || post.Privacy == types.UNLISTED) && !auth.LoggedIn(r) { + continue + } else if post.Privacy == types.DRAFT { + continue + } + } + + // Limit by tag? + if tag != "" { + var tagMatch bool + if tag != "" { + for _, check := range post.Tags { + if check == tag { + tagMatch = true + break + } + } + } + + if !tagMatch { + continue + } + } + + pool = append(pool, post) + } + + sort.Sort(sort.Reverse(posts.ByUpdated(pool))) + return pool +} + +// ViewPost is the underlying implementation of the handler to view a blog +// post, so that it can be called from non-http.HandlerFunc contexts. +// Specifically, from the catch-all page handler to allow blog URL fragments +// to map to their post. +func ViewPost(w http.ResponseWriter, r *http.Request, fragment string) error { + post, err := posts.LoadFragment(fragment) + if err != nil { + return err + } + + // Handle post privacy. + if post.Privacy == types.PRIVATE || post.Privacy == types.DRAFT { + if !auth.LoggedIn(r) { + responses.NotFound(w, r, "That post is not public.") + return nil + } + } + + v := map[string]interface{}{ + "Post": post, + } + render.Template(w, r, "blog/entry", v) + + return nil +} + +// partialPost renders a blog post as a partial template and returns the HTML. +// If indexView is true, the blog headers will be hyperlinked to the dedicated +// entry view page. +func partialPost(r *http.Request, p *posts.Post, indexView bool, numComments int) template.HTML { + // Look up the author's information. + author, err := users.LoadReadonly(p.AuthorID) + if err != nil { + log.Error("Failed to look up post author ID %d (post %d): %v", p.AuthorID, p.ID, err) + author = users.DeletedUser() + } + + // "Read More" snippet for index views. + var snipped bool + if indexView { + if strings.Contains(p.Body, "") { + 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(types.MARKDOWN) { + rendered = template.HTML(markdown.RenderTrustedMarkdown(p.Body)) + } else { + rendered = template.HTML(p.Body) + } + + meta := map[string]interface{}{ + "Post": p, + "Rendered": rendered, + "Author": author, + "IndexView": indexView, + "Snipped": snipped, + "NumComments": numComments, + } + output := bytes.Buffer{} + err = render.Template(&output, r, "blog/entry.partial", meta) + if err != nil { + return template.HTML(fmt.Sprintf("[template error in blog/entry.partial: %s]", err.Error())) + } + + return template.HTML(output.String()) +} diff --git a/core/internal/controllers/posts/tagged.go b/core/internal/controllers/posts/tagged.go new file mode 100644 index 0000000..25d5240 --- /dev/null +++ b/core/internal/controllers/posts/tagged.go @@ -0,0 +1,46 @@ +package postctl + +import ( + "bytes" + "html/template" + "net/http" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/render" +) + +// tagged lets you browse blog posts by category. +func taggedHandler(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + tag, ok := params["tag"] + if !ok { + // They're listing all the tags. + render.Template(w, r, "blog/tags.gohtml", nil) + return + } + + commonIndexHandler(w, r, tag, "") +} + +// partialTags renders the tags partial. +func partialTags(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 := map[string]interface{}{ + "IndexView": indexView, + "Tags": tags, + } + render.Template(&output, r, "blog/tags.partial", v) + + return template.HTML(output.String()) +} diff --git a/core/internal/responses/responses.go b/core/internal/responses/responses.go index 8776768..cef2963 100644 --- a/core/internal/responses/responses.go +++ b/core/internal/responses/responses.go @@ -7,6 +7,14 @@ import ( "github.com/kirsle/blog/core/internal/sessions" ) +// Error handlers to be filled in by the blog app. +var ( + NotFound func(http.ResponseWriter, *http.Request, string) + Forbidden func(http.ResponseWriter, *http.Request, string) + BadRequest func(http.ResponseWriter, *http.Request, string) + Error func(http.ResponseWriter, *http.Request, string) +) + // Flash adds a flash message to the user's session. func Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { session := sessions.Get(r) diff --git a/core/constants.go b/core/internal/types/constants.go similarity index 97% rename from core/constants.go rename to core/internal/types/constants.go index a2bd76e..d1bb841 100644 --- a/core/constants.go +++ b/core/internal/types/constants.go @@ -1,4 +1,4 @@ -package core +package types // PostPrivacy values. type PostPrivacy string diff --git a/core/pages.go b/core/pages.go index 7e0bbbd..d39bce2 100644 --- a/core/pages.go +++ b/core/pages.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/kirsle/blog/core/internal/controllers/posts" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/core/internal/responses" @@ -24,7 +25,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Restrict special paths. if strings.HasPrefix(strings.ToLower(path), "/.") { - b.Forbidden(w, r, "Forbidden") + responses.Forbidden(w, r, "Forbidden") return } @@ -32,9 +33,9 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { filepath, err := render.ResolvePath(path) if err != nil { // See if it resolves as a blog entry. - err = b.viewPost(w, r, strings.TrimLeft(path, "/")) + err = postctl.ViewPost(w, r, strings.TrimLeft(path, "/")) if err != nil { - b.NotFound(w, r, "The page you were looking for was not found.") + responses.NotFound(w, r, "The page you were looking for was not found.") } return } @@ -49,7 +50,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(filepath.URI, ".md") || strings.HasSuffix(filepath.URI, ".markdown") { source, err := ioutil.ReadFile(filepath.Absolute) if err != nil { - b.Error(w, r, "Couldn't read Markdown source!") + responses.Error(w, r, "Couldn't read Markdown source!") return } From d4321b50876a1cdb922552dab586d716dfb15a0a Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 10 Feb 2018 15:16:30 -0800 Subject: [PATCH 10/10] Small bugfixes --- core/core.go | 2 ++ core/pages.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/core/core.go b/core/core.go index d8d78cb..5081fac 100644 --- a/core/core.go +++ b/core/core.go @@ -106,6 +106,8 @@ func (b *Blog) Configure() { markdown.Cache = cache } } + + b.registerErrors() } // SetupHTTP initializes the Negroni middleware engine and registers routes. diff --git a/core/pages.go b/core/pages.go index d39bce2..6b6a068 100644 --- a/core/pages.go +++ b/core/pages.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/kirsle/blog/core/internal/controllers/posts" + "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/core/internal/responses" @@ -35,6 +36,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // See if it resolves as a blog entry. err = postctl.ViewPost(w, r, strings.TrimLeft(path, "/")) if err != nil { + log.Error("Post by fragment %s not found: %s", path, err) responses.NotFound(w, r, "The page you were looking for was not found.") } return