diff --git a/cmd/gophertype/main.go b/cmd/gophertype/main.go index 10524d0..0126791 100644 --- a/cmd/gophertype/main.go +++ b/cmd/gophertype/main.go @@ -82,5 +82,9 @@ func main() { app := gophertype.NewSite(optRoot) app.UseDB(dbDriver, dbPath) app.SetupRouter() - app.ListenAndServe(optBind) + + if err := app.ListenAndServe(optBind); err != nil { + console.Error("ListenAndServe: %s", err) + os.Exit(0) + } } diff --git a/go.mod b/go.mod index a744076..6f43051 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/jinzhu/gorm v1.9.11 github.com/kirsle/blog v0.0.0-20191022175051-d78814b9c99b github.com/kirsle/golog v0.0.0-20180411020913-51290b4f9292 + github.com/microcosm-cc/bluemonday v1.0.2 github.com/satori/go.uuid v1.2.0 + github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470 github.com/urfave/negroni v1.0.0 golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443 ) diff --git a/go.sum b/go.sum index 6dcb36d..06572c5 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,7 @@ github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsO github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q= github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= @@ -95,18 +96,27 @@ github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470 h1:qb9IthCFBmROJ6YBS31BEMeSYjOscSiG+EO+JVNTz64= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 h1:KaKXZldeYH73dpQL+Nr38j1r5BgpAYQjYvENOUpIZDQ= github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= +github.com/shurcooL/highlight_go v0.0.0-20181215221002-9d8641ddf2e1 h1:a6a6gGfBoO2ty+yyHNd7M6gkp37EwE3GIoycUnLo1Oo= github.com/shurcooL/highlight_go v0.0.0-20181215221002-9d8641ddf2e1/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/octicon v0.0.0-20181222203144-9ff1a4cf27f4 h1:H0v7bJx9CDGHx402wE08Fk5AS2mWdTYK9JI5vyrx8jQ= github.com/shurcooL/octicon v0.0.0-20181222203144-9ff1a4cf27f4/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -134,6 +144,7 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/pkg/app.go b/pkg/app.go index b55918e..ffcfde8 100644 --- a/pkg/app.go +++ b/pkg/app.go @@ -1,10 +1,13 @@ package gophertype import ( + "html/template" "net/http" "git.kirsle.net/apps/gophertype/pkg/console" + "git.kirsle.net/apps/gophertype/pkg/controllers" "git.kirsle.net/apps/gophertype/pkg/models" + "git.kirsle.net/apps/gophertype/pkg/responses" "git.kirsle.net/apps/gophertype/pkg/settings" "github.com/gorilla/mux" "github.com/jinzhu/gorm" @@ -31,6 +34,11 @@ func NewSite(pubroot string) *Site { n.Use(negroni.NewLogger()) site.n = n + // Register blog global template functions. + responses.ExtraFuncs = template.FuncMap{ + "BlogIndex": controllers.PartialBlogIndex, + } + return site } diff --git a/pkg/controllers/posts.go b/pkg/controllers/posts.go new file mode 100644 index 0000000..081ef6f --- /dev/null +++ b/pkg/controllers/posts.go @@ -0,0 +1,227 @@ +package controllers + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "strconv" + "strings" + "time" + + "git.kirsle.net/apps/gophertype/pkg/authentication" + "git.kirsle.net/apps/gophertype/pkg/glue" + "git.kirsle.net/apps/gophertype/pkg/markdown" + "git.kirsle.net/apps/gophertype/pkg/models" + "git.kirsle.net/apps/gophertype/pkg/responses" + "git.kirsle.net/apps/gophertype/pkg/session" + "git.kirsle.net/apps/gophertype/pkg/settings" + "github.com/albrow/forms" + "github.com/gorilla/mux" +) + +func init() { + glue.Register(glue.Endpoint{ + Path: "/blog", + Methods: []string{"GET"}, + Handler: BlogIndex(models.Public, false), + }) + glue.Register(glue.Endpoint{ + Path: "/tagged/{tag}", + Methods: []string{"GET"}, + Handler: BlogIndex(models.Public, true), + }) + glue.Register(glue.Endpoint{ + Path: "/blog/drafts", + Middleware: []mux.MiddlewareFunc{ + authentication.LoginRequired, + }, + Methods: []string{"GET"}, + Handler: BlogIndex(models.Draft, false), + }) + glue.Register(glue.Endpoint{ + Path: "/blog/private", + Middleware: []mux.MiddlewareFunc{ + authentication.LoginRequired, + }, + Methods: []string{"GET"}, + Handler: BlogIndex(models.Private, false), + }) + glue.Register(glue.Endpoint{ + Path: "/blog/unlisted", + Middleware: []mux.MiddlewareFunc{ + authentication.LoginRequired, + }, + Methods: []string{"GET"}, + Handler: BlogIndex(models.Unlisted, false), + }) + glue.Register(glue.Endpoint{ + Path: "/blog/edit", + Methods: []string{"GET", "POST"}, + Middleware: []mux.MiddlewareFunc{ + authentication.LoginRequired, + }, + Handler: EditPost, + }) +} + +// BlogIndex handles all of the top-level blog index routes: +// - /blog +// - /tagged/{tag} +// - /blog/unlisted +// - /blog/drafts +// - /blog/private +func BlogIndex(privacy string, tagged bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var ( + v = responses.NewTemplateVars(w, r) + tagName string + ) + + // Tagged view? + if tagged { + params := mux.Vars(r) + tagName = params["tag"] + } + + // Page title to use. + var title = "Blog" + if tagged { + title = "Tagged as: " + tagName + } else if privacy == models.Draft { + title = "Drafts" + } else if privacy == models.Unlisted { + title = "Unlisted" + } else if privacy == models.Private { + title = "Private" + } + + v.V["title"] = title + v.V["tag"] = tagName + v.V["privacy"] = privacy + responses.RenderTemplate(w, r, "_builtin/blog/index.gohtml", v) + } +} + +// PostFragment at "/" for viewing blog entries. +func PostFragment(w http.ResponseWriter, r *http.Request) { + fragment := strings.Trim(r.URL.Path, "/") + post, err := models.Posts.LoadFragment(fragment) + if err != nil { + responses.NotFound(w, r) + return + } + + // Is it a private post and are we logged in? + if post.Privacy != models.Public && post.Privacy != models.Unlisted && !authentication.LoggedIn(r) { + responses.Forbidden(w, r, "Permission denied to view that post.") + return + } + + v := responses.NewTemplateVars(w, r) + v.V["post"] = post + + // Render the body. + if post.ContentType == models.Markdown { + v.V["rendered"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) + } else { + v.V["rendered"] = template.HTML(post.Body) + } + + responses.RenderTemplate(w, r, "_builtin/blog/view-post.gohtml", v) +} + +// PartialBlogIndex is a template function to embed a blog index view on any page. +func PartialBlogIndex(r *http.Request, tag, privacy string) template.HTML { + html := bytes.NewBuffer([]byte{}) + v := responses.NewTemplateVars(html, r) + page, _ := strconv.Atoi(r.FormValue("page")) + + var ( + posts models.PagedPosts + err error + ) + + if tag != "" { + posts, err = models.Posts.GetPostsByTag(tag, privacy, page, settings.Current.PostsPerPage) + } else { + posts, err = models.Posts.GetIndexPosts(privacy, page, settings.Current.PostsPerPage) + } + + if err != nil && err.Error() != "sql: no rows in result set" { + return template.HTML(fmt.Sprintf("[BlogIndex: %s]", err)) + } + + v.V["posts"] = posts.Posts + v.V["paging"] = posts + responses.PartialTemplate(html, r, "_builtin/blog/index.partial.gohtml", v) + return template.HTML(html.String()) +} + +// EditPost at "/blog/edit" +func EditPost(w http.ResponseWriter, r *http.Request) { + v := responses.NewTemplateVars(w, r) + v.V["preview"] = "" + + // The blog post we're working with. + var post = models.Posts.New() + var isNew = true + + // Editing an existing post? + if r.FormValue("id") != "" { + id, _ := strconv.Atoi(r.FormValue("id")) + if p, err := models.Posts.Load(id); err == nil { + post = p + isNew = false + } + } + + // POST handler: create the admin account. + for r.Method == http.MethodPost { + form, _ := forms.Parse(r) + + // Validate form parameters. + val := form.Validator() + val.Require("title") + val.Require("body") + post.ParseForm(form) + if val.HasErrors() { + v.ValidationError = val.ErrorMap() + break + } + + // Previewing or submitting the post? + switch form.Get("submit") { + case "preview": + if post.ContentType == models.Markdown { + v.V["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) + } else { + v.V["preview"] = template.HTML(post.Body) + } + case "post": + author, _ := authentication.CurrentUser(r) + post.AuthorID = author.ID + + // When editing, allow to not touch the Last Updated time. + if !isNew && form.GetBool("no-update") == true { + post.UpdatedAt = post.CreatedAt + } else { + post.UpdatedAt = time.Now().UTC() + } + + err := post.Save() + if err != nil { + v.Error = err + } else { + session.Flash(w, r, "Post created!") + responses.Redirect(w, r, "/"+post.Fragment) + } + } + + break + } + + v.V["post"] = post + v.V["isNew"] = isNew + responses.RenderTemplate(w, r, "_builtin/blog/edit.gohtml", v) +} diff --git a/pkg/controllers/static_files.go b/pkg/controllers/static_files.go index a3b068f..dcb1457 100644 --- a/pkg/controllers/static_files.go +++ b/pkg/controllers/static_files.go @@ -5,6 +5,7 @@ import ( "strings" "git.kirsle.net/apps/gophertype/pkg/console" + "git.kirsle.net/apps/gophertype/pkg/models" "git.kirsle.net/apps/gophertype/pkg/responses" ) @@ -15,6 +16,19 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) { path := r.URL.Path console.Debug("Wildcard path: %s", path) + // Is it a blog fragment? + if _, err := models.Posts.LoadFragment(path); err == nil { + PostFragment(w, r) + return + } + + // No dot-files allowed. + if strings.Contains(path, "/.") { + console.Error("Path '%s' contains a dotfile; forbidden", path) + responses.Forbidden(w, r, "You're not supposed to be here.") + return + } + // Resolve the target path. filepath, err := responses.ResolveFile(path) if err != nil { @@ -32,6 +46,9 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(filepath, ".gohtml") { responses.RenderTemplate(w, r, filepath, nil) return + } else if strings.HasSuffix(filepath, ".md") { + responses.RenderMarkdown(w, r, filepath) + return } http.ServeFile(w, r, "pvt-www/"+filepath) diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go new file mode 100644 index 0000000..895fadd --- /dev/null +++ b/pkg/markdown/markdown.go @@ -0,0 +1,162 @@ +// Package markdown implements a GitHub Flavored Markdown renderer. +package markdown + +import ( + "bytes" + "crypto/md5" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "strings" + + "git.kirsle.net/apps/gophertype/pkg/console" + "github.com/microcosm-cc/bluemonday" + "github.com/shurcooL/github_flavored_markdown" +) + +// Regexps for Markdown use cases. +var ( + // TODO: Redis caching + // Cache interface{} = nil + + // 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*` + "```") + 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. + reCodeBlock = regexp.MustCompile(`
(.+?)
`) + reDecodeBlock = regexp.MustCompile(`\[?FENCED_CODE_%d_BLOCK?\]`) +) + +// A container for parsed code blocks. +type codeBlock struct { + placeholder int + language string + source string +} + +// TitleFromMarkdown tries to find a title from the source of a Markdown file. +// +// On error, returns "Untitled" along with the error. So if you're lazy and +// want a suitable default, you can safely ignore the error. +func TitleFromMarkdown(body string) (string, error) { + m := reMarkdownTitle.FindStringSubmatch(body) + if len(m) > 0 { + return m[1], nil + } + return "Untitled", errors.New( + "did not find a single h1 (denoted by # prefix) for Markdown title", + ) +} + +// RenderMarkdown renders markdown to HTML, safely. It uses blackfriday to +// render Markdown to HTML and then Bluemonday to sanitize the resulting HTML. +func RenderMarkdown(input string) string { + unsafe := []byte(RenderTrustedMarkdown(input)) + + // Sanitize HTML, but allow fenced code blocks to not get mangled in user + // submitted comments. + p := bluemonday.UGCPolicy() + p.AllowAttrs("class").Matching(reFencedCodeClass).OnElements("code") + html := p.SanitizeBytes(unsafe) + return string(html) +} + +// 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 RenderTrustedMarkdown(input string) string { + // Find and hang on to fenced code blocks. + codeBlocks := []codeBlock{} + matches := reFencedCode.FindAllStringSubmatch(input, -1) + for i, m := range matches { + language, source := m[1], m[2] + if language == "" { + continue + } + codeBlocks = append(codeBlocks, codeBlock{i, language, source}) + + input = strings.Replace(input, m[0], fmt.Sprintf( + "[?FENCED_CODE_%d_BLOCK?]", + i, + ), 1) + } + + // Render the HTML out. + html := string(github_flavored_markdown.Markdown([]byte(input))) + + // Substitute fenced codes back in. + for _, block := range codeBlocks { + highlighted, err := Pygmentize(block.language, block.source) + if err != nil { + console.Error("Pygmentize error: %s", err) + } + html = strings.Replace(html, + fmt.Sprintf("[?FENCED_CODE_%d_BLOCK?]", block.placeholder), + highlighted, + 1, + ) + } + + return string(html) +} + +// Pygmentize searches for fenced code blocks in rendered Markdown HTML +// and runs Pygments to syntax highlight it. +// +// On error the original given source is returned back. +// +// 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 Pygmentize(language, source string) (string, error) { + var result string + + // Hash the source for the cache key. + h := md5.New() + io.WriteString(h, language+source) + // hash := fmt.Sprintf("%x", h.Sum(nil)) + // cacheKey := "pygmentize:" + hash + + // Do we have it cached? + // if Cache != nil { + // if cached, err := Cache.Get(cacheKey); err == nil && len(cached) > 0 { + // return string(cached), nil + // } + // } + + // Defer to the `pygmentize` command + bin := "pygmentize" + if _, err := exec.LookPath(bin); err != nil { + return source, errors.New("pygmentize not installed") + } + + cmd := exec.Command(bin, "-l"+language, "-f"+"html", "-O encoding=utf-8") + cmd.Stdin = strings.NewReader(source) + + var out bytes.Buffer + cmd.Stdout = &out + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + console.Error("Error running pygments: %s", stderr.String()) + return source, err + } + + result = out.String() + // if Cache != nil { + // err := Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change + // if err != nil { + // console.Error("Couldn't cache Pygmentize output: %s", err) + // } + // } + + return result, nil +} diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go index 3f04a92..1d9ed48 100644 --- a/pkg/middleware/csrf.go +++ b/pkg/middleware/csrf.go @@ -1,11 +1,13 @@ package middleware import ( + "context" "net/http" "time" "git.kirsle.net/apps/gophertype/pkg/constants" "git.kirsle.net/apps/gophertype/pkg/responses" + "git.kirsle.net/apps/gophertype/pkg/session" uuid "github.com/satori/go.uuid" ) @@ -32,6 +34,11 @@ func CSRF(next http.Handler) http.Handler { http.SetCookie(w, cookie) } + // Add the CSRF token to the request context. This makes it immediately + // available on FIRST page load, when the cookie hasn't been sent back + // from the browser yet. + ctx := context.WithValue(r.Context(), session.CSRFKey, token) + // POST requests: verify token from form parameter. if r.Method == http.MethodPost { compare := r.FormValue(constants.CSRFFormName) @@ -41,7 +48,7 @@ func CSRF(next http.Handler) http.Handler { } } - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(ctx)) } return http.HandlerFunc(middleware) diff --git a/pkg/models/constants.go b/pkg/models/constants.go new file mode 100644 index 0000000..04aea7e --- /dev/null +++ b/pkg/models/constants.go @@ -0,0 +1,14 @@ +package models + +// Constant values for blog posts. +const ( + // ContentType settings. + HTML = "html" + Markdown = "markdown" + + // Post privacy settings. + Public = "public" + Private = "private" + Unlisted = "unlisted" + Draft = "draft" +) diff --git a/pkg/models/models.go b/pkg/models/models.go index 72e3d9b..f749d22 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -9,4 +9,6 @@ var DB *gorm.DB func UseDB(db *gorm.DB) { DB = db DB.AutoMigrate(&User{}) + DB.AutoMigrate(&Post{}) + DB.AutoMigrate(&TaggedPost{}) } diff --git a/pkg/models/posts.go b/pkg/models/posts.go new file mode 100644 index 0000000..fa1abb6 --- /dev/null +++ b/pkg/models/posts.go @@ -0,0 +1,313 @@ +package models + +import ( + "errors" + "fmt" + "html/template" + "math" + "regexp" + "strings" + "time" + + "git.kirsle.net/apps/gophertype/pkg/console" + "git.kirsle.net/apps/gophertype/pkg/markdown" + "github.com/albrow/forms" + "github.com/jinzhu/gorm" +) + +type postMan struct{} + +// Posts is a singleton manager class for Post model access. +var Posts = postMan{} + +// Post represents a single blog entry. +type Post struct { + gorm.Model + + Title string + Fragment string `gorm:"unique_index"` + ContentType string `gorm:"default:html"` + AuthorID uint // foreign key to User.ID + Body string + Privacy string + + Sticky bool + EnableComments bool + Tags []TaggedPost + Author User `gorm:"foreign_key:UserID"` +} + +// PagedPosts holds a paginated response of multiple posts. +type PagedPosts struct { + Posts []Post + Page int + PerPage int + Pages int + Total int + NextPage int + PreviousPage int +} + +// TaggedPost associates tags to their posts. +type TaggedPost struct { + ID uint `gorm:"primary_key"` + Tag string + PostID uint // foreign key to Post +} + +// New creates a new Post model. +func (m postMan) New() Post { + return Post{ + ContentType: Markdown, + Privacy: Public, + EnableComments: true, + } +} + +// Load a post by ID. +func (m postMan) Load(id int) (Post, error) { + var post Post + r := DB.Preload("Author").Preload("Tags").First(&post, id) + return post, r.Error +} + +// LoadFragment loads a blog post by its URL fragment. +func (m postMan) LoadFragment(fragment string) (Post, error) { + var post Post + r := DB.Preload("Author").Preload("Tags").Where("fragment = ?", strings.Trim(fragment, "/")).First(&post) + return post, r.Error +} + +// GetIndex returns the index page of blog posts. +func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, error) { + var pp = PagedPosts{ + Page: page, + PerPage: perPage, + } + + if pp.Page < 1 { + pp.Page = 1 + } + if pp.PerPage <= 0 { + pp.PerPage = 20 + } + + query := DB.Debug().Preload("Author").Preload("Tags"). + Where("privacy = ?", privacy). + Order("sticky desc, created_at desc") + + // Count the total number of rows for paging purposes. + query.Model(&Post{}).Count(&pp.Total) + + // Query the paginated slice of results. + r := query. + Offset((page - 1) * perPage). + Limit(perPage). + Find(&pp.Posts) + + pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage))) + if pp.Page < pp.Pages { + pp.NextPage = pp.Page + 1 + } + if pp.Page > 1 { + pp.PreviousPage = pp.Page - 1 + } + + return pp, r.Error +} + +// GetPostsByTag gets posts by a certain tag. +func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPosts, error) { + var pp = PagedPosts{ + Page: page, + PerPage: perPage, + } + + if pp.Page < 1 { + pp.Page = 1 + } + if pp.PerPage <= 0 { + pp.PerPage = 20 + } + + // Get the distinct post IDs for this tag. + var tags []TaggedPost + var postIDs []uint + r := DB.Where("tag = ?", tag).Find(&tags) + for _, taggedPost := range tags { + postIDs = append(postIDs, taggedPost.PostID) + } + + if len(postIDs) == 0 { + return pp, errors.New("no posts found") + } + + // Query this set of posts. + query := DB.Debug().Preload("Author").Preload("Tags"). + Where("id IN (?) AND privacy = ?", postIDs, privacy). + Order("sticky desc, created_at desc") + + // Count the total number of rows for paging purposes. + query.Model(&Post{}).Count(&pp.Total) + + // Query the paginated slice of results. + r = query. + Offset((page - 1) * perPage). + Limit(perPage). + Find(&pp.Posts) + + pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage))) + if pp.Page < pp.Pages { + pp.NextPage = pp.Page + 1 + } + if pp.Page > 1 { + pp.PreviousPage = pp.Page - 1 + } + + return pp, r.Error +} + +// PreviewHTML returns the post's body as rendered HTML code, but only above +// the tag for index views. +func (p Post) PreviewHTML() template.HTML { + var ( + parts = strings.Split(p.Body, "") + hasMore = len(parts) > 1 + body = strings.TrimSpace(parts[0]) + ) + + if p.ContentType == Markdown { + if hasMore { + body += fmt.Sprintf("\n\n[Read more...](/%s)", p.Fragment) + } + return template.HTML(markdown.RenderTrustedMarkdown(body)) + } + + body += fmt.Sprintf(`

Read more...

`, p.Fragment) + return template.HTML(body) +} + +// HTML returns the post's body as rendered HTML code. +func (p Post) HTML() template.HTML { + body := strings.ReplaceAll(p.Body, "", "") + if p.ContentType == Markdown { + return template.HTML(markdown.RenderTrustedMarkdown(body)) + } + return template.HTML(body) +} + +// Save a post. +// This method also makes sure a unique Fragment is set and links the Tags correctly. +func (p *Post) Save() error { + // Generate the default fragment from the post title. + if p.Fragment == "" { + fragment := strings.ToLower(p.Title) + fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-") + fragment = strings.ReplaceAll(fragment, "--", "-") + console.Error("frag: %s", fragment) + p.Fragment = strings.Trim(fragment, "-") + + // If still no fragment, make one up from the current time. + if p.Fragment == "" { + p.Fragment = time.Now().Format("2006-01-02-150405") + } + } + + // Ensure the fragment is unique! + { + if exist, err := Posts.LoadFragment(p.Fragment); err != nil && exist.ID != p.ID { + console.Debug("Post.Save: fragment %s is not unique, trying to resolve", p.Fragment) + var resolved bool + for i := 2; i <= 100; i++ { + fragment := fmt.Sprintf("%s-%d", p.Fragment, i) + console.Debug("Post.Save: try fragment '%s'", fragment) + _, err = Posts.LoadFragment(fragment) + if err == nil { + continue + } + + p.Fragment = fragment + resolved = true + break + } + + if !resolved { + return fmt.Errorf("failed to generate a unique URL fragment for '%s' after 100 attempts", p.Fragment) + } + } + } + + // Empty tags list. + if len(p.Tags) == 1 && p.Tags[0].Tag == "" { + p.Tags = []TaggedPost{} + } + + // TODO: tag relationships. For now just delete and re-add them all. + if p.ID != 0 { + DB.Where("post_id = ?", p.ID).Delete(TaggedPost{}) + } + + // Dedupe tags. + p.fixTags() + + // Save the post. + if DB.NewRecord(p) { + return DB.Create(&p).Error + } + + return DB.Save(&p).Error +} + +// ParseForm populates a Post from an HTTP form. +func (p *Post) ParseForm(form *forms.Data) { + p.Title = form.Get("title") + p.Fragment = form.Get("fragment") + p.ContentType = form.Get("content-type") + p.Body = form.Get("body") + p.Privacy = form.Get("privacy") + p.Sticky = form.GetBool("sticky") + p.EnableComments = form.GetBool("enable-comments") + + // Parse the tags array. This replaces the post.Tags with an empty TaggedPost + // list containing only the string Tag values. The IDs and DB side will be + // patched up when the post gets saved. + p.Tags = []TaggedPost{} + tags := strings.Split(form.Get("tags"), ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if len(tag) == 0 { + continue + } + + p.Tags = append(p.Tags, TaggedPost{ + Tag: tag, + }) + } +} + +// TagsString turns the post tags into a comma separated string. +func (p Post) TagsString() string { + console.Error("TagsString: %+v", p.Tags) + var tags = make([]string, len(p.Tags)) + for i, tag := range p.Tags { + tags[i] = tag.Tag + } + return strings.Join(tags, ", ") +} + +// fixTags is a pre-Save function to fix up the Tags relationships. +// It checks that each tag has an ID, and if it doesn't have an ID yet, removes +// it if a duplicate tag does exist that has an ID. +func (p *Post) fixTags() { + // De-duplicate tag values. + var dedupe = map[string]interface{}{} + var finalTags []TaggedPost + for _, tag := range p.Tags { + if _, ok := dedupe[tag.Tag]; !ok { + finalTags = append(finalTags, tag) + dedupe[tag.Tag] = nil + } + } + + p.Tags = finalTags +} diff --git a/pkg/models/users.go b/pkg/models/users.go index 71795b7..08d4bc8 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -14,10 +14,13 @@ import ( // User account for the site. type User struct { gorm.Model - Email string `json:"email" gorm:"unique_index"` - Name string `json:"name"` + Email string `gorm:"unique_index"` + Name string HashedPassword string `json:"-"` - IsAdmin bool `json:"isAdmin" gorm:"index"` + IsAdmin bool `gorm:"index"` + + // Relationships + Posts []Post `gorm:"foreignkey:AuthorID"` } // Validate the User object has everything filled in. Fixes what it can, diff --git a/pkg/responses/errors.go b/pkg/responses/errors.go index 5caddbb..c268fee 100644 --- a/pkg/responses/errors.go +++ b/pkg/responses/errors.go @@ -2,14 +2,17 @@ package responses import ( "fmt" + "io" "net/http" "git.kirsle.net/apps/gophertype/pkg/console" ) // Panic gives a simple error with no template or anything fancy. -func Panic(w http.ResponseWriter, code int, message string) { - w.WriteHeader(code) +func Panic(w io.Writer, code int, message string) { + if rw, ok := w.(http.ResponseWriter); ok { + rw.WriteHeader(code) + } w.Write([]byte(message)) } diff --git a/pkg/responses/template_functions.go b/pkg/responses/template_functions.go index f8eb624..d71d4f0 100644 --- a/pkg/responses/template_functions.go +++ b/pkg/responses/template_functions.go @@ -6,26 +6,38 @@ import ( "net/http" "git.kirsle.net/apps/gophertype/pkg/constants" + "git.kirsle.net/apps/gophertype/pkg/session" ) +// ExtraFuncs lets the core app inject extra template functions for all templates. +// Use cases include inserting comment or blog partials on other pages. +var ExtraFuncs template.FuncMap + // TemplateFuncs available to all templates. func TemplateFuncs(r *http.Request) template.FuncMap { - return template.FuncMap{ + funcs := template.FuncMap{ "CSRF": CSRF(r), "FormValue": FormValue(r), "TestFunction": TestFunction(r), } + for k, v := range ExtraFuncs { + funcs[k] = v + } + return funcs } // CSRF returns the current CSRF token as an HTML hidden form field. func CSRF(r *http.Request) func() template.HTML { return func() template.HTML { - token, _ := r.Cookie(constants.CSRFCookieName) - return template.HTML(fmt.Sprintf( - ``, - constants.CSRFFormName, - token.Value, - )) + ctx := r.Context() + if token, ok := ctx.Value(session.CSRFKey).(string); ok { + return template.HTML(fmt.Sprintf( + ``, + constants.CSRFFormName, + token, + )) + } + return template.HTML("[error: csrf token not found in request context]") } } diff --git a/pkg/responses/template_vars.go b/pkg/responses/template_vars.go index 8d5c697..9c9f327 100644 --- a/pkg/responses/template_vars.go +++ b/pkg/responses/template_vars.go @@ -2,6 +2,7 @@ package responses import ( "fmt" + "io" "net/http" "net/url" "time" @@ -14,7 +15,7 @@ import ( ) // NewTemplateVars creates the TemplateVars for your current request. -func NewTemplateVars(w http.ResponseWriter, r *http.Request) TemplateValues { +func NewTemplateVars(w io.Writer, r *http.Request) TemplateValues { var s = settings.Current user, _ := authentication.CurrentUser(r) @@ -33,8 +34,13 @@ func NewTemplateVars(w http.ResponseWriter, r *http.Request) TemplateValues { IsAdmin: user.IsAdmin, CurrentUser: user, - Flashes: session.GetFlashes(w, r), + V: map[string]interface{}{}, } + + if rw, ok := w.(http.ResponseWriter); ok { + v.Flashes = session.GetFlashes(rw, r) + } + return v } @@ -68,7 +74,7 @@ type TemplateValues struct { Flashes []string // Arbitrary controller-specific fields go in V. - V interface{} + V map[string]interface{} } // Flash adds a message to flash on the next template render. diff --git a/pkg/responses/templates.go b/pkg/responses/templates.go index ed31cdc..2882d79 100644 --- a/pkg/responses/templates.go +++ b/pkg/responses/templates.go @@ -2,14 +2,16 @@ package responses import ( "html/template" + "io" "net/http" "git.kirsle.net/apps/gophertype/pkg/console" + "git.kirsle.net/apps/gophertype/pkg/markdown" ) // RenderTemplate renders a Go HTML template. // The io.Writer can be an http.ResponseWriter. -func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, vars interface{}) error { +func RenderTemplate(w io.Writer, r *http.Request, tmpl string, vars interface{}) error { if vars == nil { vars = NewTemplateVars(w, r) } @@ -43,3 +45,46 @@ func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, vars in Panic(w, http.StatusInternalServerError, err.Error()) return nil } + +// RenderMarkdown renders a Markdown page in the layout template. +func RenderMarkdown(w io.Writer, r *http.Request, tmpl string) error { + vars := NewTemplateVars(w, r) + + b, err := GetFile(tmpl) + if err == nil { + body := string(b) + title, _ := markdown.TitleFromMarkdown(body) + vars.V["title"] = title + vars.V["markdown"] = template.HTML(markdown.RenderTrustedMarkdown(body)) + RenderTemplate(w, r, "_builtin/markdown.gohtml", vars) + return nil + } + + Panic(w, http.StatusInternalServerError, err.Error()) + return nil +} + +// PartialTemplate renders a partial HTML template. +func PartialTemplate(w io.Writer, r *http.Request, tmpl string, vars interface{}) error { + if vars == nil { + vars = NewTemplateVars(w, r) + } + + // Look for the built-in template. + b, err := GetFile(tmpl) + if err == nil { + t, err := template.New(tmpl).Funcs(TemplateFuncs(r)).Parse(string(b)) + if err != nil { + console.Error("RenderTemplate: bundled template '%s': %s", tmpl, err) + return err + } + + if err := t.ExecuteTemplate(w, tmpl, vars); err != nil { + console.Error("PartialTemplate(%s): %s", tmpl, err) + } + return nil + } + + Panic(w, http.StatusInternalServerError, err.Error()) + return nil +} diff --git a/pkg/session/keys.go b/pkg/session/keys.go index cd5afbd..0294364 100644 --- a/pkg/session/keys.go +++ b/pkg/session/keys.go @@ -8,4 +8,5 @@ const ( SessionKey Key = iota // The request's cookie session object. UserKey // The request's user data for logged-in user. StartTimeKey // The start time of the request. + CSRFKey // CSRF token ) diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go index 09d72d5..141ee99 100644 --- a/pkg/settings/settings.go +++ b/pkg/settings/settings.go @@ -95,9 +95,10 @@ func SetFilename(userRoot string) error { // Load gets or creates the App Settings. func Load() Spec { var s = Spec{ - Title: "Untitled Site", - Description: "Just another web blog.", - SecretKey: MakeSecretKey(), + Title: "Untitled Site", + Description: "Just another web blog.", + SecretKey: MakeSecretKey(), + PostsPerPage: 20, } session.SetSecretKey([]byte(s.SecretKey)) diff --git a/pvt-www/.layout.gohtml b/pvt-www/.layout.gohtml index cde3a2d..bb53691 100644 --- a/pvt-www/.layout.gohtml +++ b/pvt-www/.layout.gohtml @@ -121,6 +121,7 @@
  • Post Blog Entry
  • View Drafts
  • View Private
  • +
  • View Unlisted
  • diff --git a/pvt-www/_builtin/blog/edit.gohtml b/pvt-www/_builtin/blog/edit.gohtml new file mode 100644 index 0000000..6b0c764 --- /dev/null +++ b/pvt-www/_builtin/blog/edit.gohtml @@ -0,0 +1,133 @@ +{{ define "title" }}Update Blog{{ end }} +{{ define "content" }} +

    Update Blog

    + +{{ if .V.preview }} +
    +
    + Preview +
    +
    + {{ .V.preview }} +
    +
    +{{ end }} + +{{ $Post := .V.post }} + +
    + {{ CSRF }} + +
    +
    +
    + + +
    + +
    + + + + You can leave this blank if writing a new post; it will automatically + get a unique fragment based on the post title. + +
    + +
    +
    + + + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + +
    + + +
    + +
    + + +
    + + {{ if not .V.isNew }} +
    + + +
    + {{ end }} + +
    + + + +
    +
    +
    +
    +{{ end }} diff --git a/pvt-www/_builtin/blog/index.gohtml b/pvt-www/_builtin/blog/index.gohtml new file mode 100644 index 0000000..d8e02e4 --- /dev/null +++ b/pvt-www/_builtin/blog/index.gohtml @@ -0,0 +1,8 @@ +{{ define "title" }}{{ or .V.title "Blog" }}{{ end }} +{{ define "content" }} + +

    {{ or .V.title "Blog" }}

    + +{{ BlogIndex .Request .V.tag .V.privacy }} + +{{ end }} diff --git a/pvt-www/_builtin/blog/index.partial.gohtml b/pvt-www/_builtin/blog/index.partial.gohtml new file mode 100644 index 0000000..dd739ca --- /dev/null +++ b/pvt-www/_builtin/blog/index.partial.gohtml @@ -0,0 +1,77 @@ +{{ range $i, $Post := .V.posts }} + {{ if gt $i 0 }}
    {{ end }} + +
    + +
    + + {{ if $Post.Sticky }}[sticky]{{ end }} + {{ if ne $Post.Privacy "public" }} + [{{ $Post.Privacy }}] + {{ end }} + + + {{ $Post.CreatedAt.Format "January 2, 2006" }} + + + {{ if ($Post.UpdatedAt.After $Post.CreatedAt) }} + + (updated {{ $Post.UpdatedAt.Format "January 2, 2006" }}) + + {{ end }} + + {{ if $Post.Author.Name }} + by {{ $Post.Author.Name }} + {{ end }} + +

    + + {{ $Post.PreviewHTML }} + +
    + + Tags: + + {{ range $tag := $Post.Tags }} + #{{ $tag.Tag }} + {{ end }} + +
    +
    +
    + + {{ if $.CurrentUser.IsAdmin }} +
    + + Admin: + [ + edit | + delete + ] + +
    + {{ end }} +{{ end }} + +{{ if .V.paging }} +
    +
    + + Page {{ .V.paging.Page }} of {{ .V.paging.Pages }} + +
    + +
    + {{ if .V.paging.PreviousPage }} + Newer posts + {{ end }} + {{ if .V.paging.NextPage }} + Older posts + {{ end }} +
    +
    +{{ end }} diff --git a/pvt-www/_builtin/blog/view-post.gohtml b/pvt-www/_builtin/blog/view-post.gohtml new file mode 100644 index 0000000..2bfbdd0 --- /dev/null +++ b/pvt-www/_builtin/blog/view-post.gohtml @@ -0,0 +1,56 @@ +{{ define "title" }}{{ .V.post.Title }}{{ end }} +{{ define "content" }} +{{ $Post := .V.post }} + +
    +
    +

    {{ $Post.Title }}

    +
    +
    + + {{ if $Post.Sticky }}[sticky]{{ end }} + {{ if ne $Post.Privacy "public" }} + [{{ $Post.Privacy }}] + {{ end }} + + + {{ $Post.CreatedAt.Format "January 2, 2006" }} + + + {{ if ($Post.UpdatedAt.After $Post.CreatedAt) }} + + (updated {{ $Post.UpdatedAt.Format "January 2, 2006" }}) + + {{ end }} + + {{ if $Post.Author.Name }} + by {{ $Post.Author.Name }} + {{ end }} + +

    + + {{ $Post.HTML }} + +
    + + Tags: + + {{ range $tag := $Post.Tags }} + #{{ $tag.Tag }} + {{ end }} + +
    +
    +
    + +{{ if .CurrentUser.IsAdmin }} +
    + Admin: + [ + edit | + delete + ] +
    +{{ end }} + +{{ end }} diff --git a/pvt-www/_builtin/markdown.gohtml b/pvt-www/_builtin/markdown.gohtml new file mode 100644 index 0000000..308863f --- /dev/null +++ b/pvt-www/_builtin/markdown.gohtml @@ -0,0 +1,6 @@ +{{ define "title" }}{{ or .V.title "Untitled Markdown Document" }}{{ end }} +{{ define "content" }} + +{{ .V.markdown }} + +{{ end }} diff --git a/pvt-www/index.gohtml b/pvt-www/index.gohtml index 11550ff..b64ced2 100644 --- a/pvt-www/index.gohtml +++ b/pvt-www/index.gohtml @@ -6,4 +6,7 @@ This is your index page. You can edit it and put whatever you want here. By default, the blog index is also embedded on the website's index page.

    + +{{ BlogIndex .Request "" "public" }} + {{ end }}