Basic Blog Functionality & Permissions

master
Noah 2019-11-26 16:54:02 -08:00
parent 0b04dad045
commit 117542b23c
25 changed files with 1143 additions and 21 deletions

View File

@ -82,5 +82,9 @@ func main() {
app := gophertype.NewSite(optRoot) app := gophertype.NewSite(optRoot)
app.UseDB(dbDriver, dbPath) app.UseDB(dbDriver, dbPath)
app.SetupRouter() app.SetupRouter()
app.ListenAndServe(optBind)
if err := app.ListenAndServe(optBind); err != nil {
console.Error("ListenAndServe: %s", err)
os.Exit(0)
}
} }

2
go.mod
View File

@ -9,7 +9,9 @@ require (
github.com/jinzhu/gorm v1.9.11 github.com/jinzhu/gorm v1.9.11
github.com/kirsle/blog v0.0.0-20191022175051-d78814b9c99b github.com/kirsle/blog v0.0.0-20191022175051-d78814b9c99b
github.com/kirsle/golog v0.0.0-20180411020913-51290b4f9292 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/satori/go.uuid v1.2.0
github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470
github.com/urfave/negroni v1.0.0 github.com/urfave/negroni v1.0.0
golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443 golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443
) )

11
go.sum
View File

@ -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 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 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/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/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/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= 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-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/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/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/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/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 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 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/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/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_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/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/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/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/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/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/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/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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-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-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-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/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-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@ -1,10 +1,13 @@
package gophertype package gophertype
import ( import (
"html/template"
"net/http" "net/http"
"git.kirsle.net/apps/gophertype/pkg/console" "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/models"
"git.kirsle.net/apps/gophertype/pkg/responses"
"git.kirsle.net/apps/gophertype/pkg/settings" "git.kirsle.net/apps/gophertype/pkg/settings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
@ -31,6 +34,11 @@ func NewSite(pubroot string) *Site {
n.Use(negroni.NewLogger()) n.Use(negroni.NewLogger())
site.n = n site.n = n
// Register blog global template functions.
responses.ExtraFuncs = template.FuncMap{
"BlogIndex": controllers.PartialBlogIndex,
}
return site return site
} }

227
pkg/controllers/posts.go Normal file
View File

@ -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 "/<fragment>" 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)
}

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"git.kirsle.net/apps/gophertype/pkg/console" "git.kirsle.net/apps/gophertype/pkg/console"
"git.kirsle.net/apps/gophertype/pkg/models"
"git.kirsle.net/apps/gophertype/pkg/responses" "git.kirsle.net/apps/gophertype/pkg/responses"
) )
@ -15,6 +16,19 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
console.Debug("Wildcard path: %s", 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. // Resolve the target path.
filepath, err := responses.ResolveFile(path) filepath, err := responses.ResolveFile(path)
if err != nil { if err != nil {
@ -32,6 +46,9 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(filepath, ".gohtml") { if strings.HasSuffix(filepath, ".gohtml") {
responses.RenderTemplate(w, r, filepath, nil) responses.RenderTemplate(w, r, filepath, nil)
return return
} else if strings.HasSuffix(filepath, ".md") {
responses.RenderMarkdown(w, r, filepath)
return
} }
http.ServeFile(w, r, "pvt-www/"+filepath) http.ServeFile(w, r, "pvt-www/"+filepath)

162
pkg/markdown/markdown.go Normal file
View File

@ -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(`<div class="highlight highlight-(.+?)"><pre>(.+?)</pre></div>`)
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
}

View File

@ -1,11 +1,13 @@
package middleware package middleware
import ( import (
"context"
"net/http" "net/http"
"time" "time"
"git.kirsle.net/apps/gophertype/pkg/constants" "git.kirsle.net/apps/gophertype/pkg/constants"
"git.kirsle.net/apps/gophertype/pkg/responses" "git.kirsle.net/apps/gophertype/pkg/responses"
"git.kirsle.net/apps/gophertype/pkg/session"
uuid "github.com/satori/go.uuid" uuid "github.com/satori/go.uuid"
) )
@ -32,6 +34,11 @@ func CSRF(next http.Handler) http.Handler {
http.SetCookie(w, cookie) 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. // POST requests: verify token from form parameter.
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
compare := r.FormValue(constants.CSRFFormName) 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) return http.HandlerFunc(middleware)

14
pkg/models/constants.go Normal file
View File

@ -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"
)

View File

@ -9,4 +9,6 @@ var DB *gorm.DB
func UseDB(db *gorm.DB) { func UseDB(db *gorm.DB) {
DB = db DB = db
DB.AutoMigrate(&User{}) DB.AutoMigrate(&User{})
DB.AutoMigrate(&Post{})
DB.AutoMigrate(&TaggedPost{})
} }

313
pkg/models/posts.go Normal file
View File

@ -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 <snip> tag for index views.
func (p Post) PreviewHTML() template.HTML {
var (
parts = strings.Split(p.Body, "<snip>")
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(`<p><a href="/%s">Read more...</a></p>`, 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, "<snip>", "")
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
}

View File

@ -14,10 +14,13 @@ import (
// User account for the site. // User account for the site.
type User struct { type User struct {
gorm.Model gorm.Model
Email string `json:"email" gorm:"unique_index"` Email string `gorm:"unique_index"`
Name string `json:"name"` Name string
HashedPassword string `json:"-"` 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, // Validate the User object has everything filled in. Fixes what it can,

View File

@ -2,14 +2,17 @@ package responses
import ( import (
"fmt" "fmt"
"io"
"net/http" "net/http"
"git.kirsle.net/apps/gophertype/pkg/console" "git.kirsle.net/apps/gophertype/pkg/console"
) )
// Panic gives a simple error with no template or anything fancy. // Panic gives a simple error with no template or anything fancy.
func Panic(w http.ResponseWriter, code int, message string) { func Panic(w io.Writer, code int, message string) {
w.WriteHeader(code) if rw, ok := w.(http.ResponseWriter); ok {
rw.WriteHeader(code)
}
w.Write([]byte(message)) w.Write([]byte(message))
} }

View File

@ -6,26 +6,38 @@ import (
"net/http" "net/http"
"git.kirsle.net/apps/gophertype/pkg/constants" "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. // TemplateFuncs available to all templates.
func TemplateFuncs(r *http.Request) template.FuncMap { func TemplateFuncs(r *http.Request) template.FuncMap {
return template.FuncMap{ funcs := template.FuncMap{
"CSRF": CSRF(r), "CSRF": CSRF(r),
"FormValue": FormValue(r), "FormValue": FormValue(r),
"TestFunction": TestFunction(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. // CSRF returns the current CSRF token as an HTML hidden form field.
func CSRF(r *http.Request) func() template.HTML { func CSRF(r *http.Request) func() template.HTML {
return func() template.HTML { return func() template.HTML {
token, _ := r.Cookie(constants.CSRFCookieName) ctx := r.Context()
return template.HTML(fmt.Sprintf( if token, ok := ctx.Value(session.CSRFKey).(string); ok {
`<input type="hidden" name="%s" value="%s">`, return template.HTML(fmt.Sprintf(
constants.CSRFFormName, `<input type="hidden" name="%s" value="%s">`,
token.Value, constants.CSRFFormName,
)) token,
))
}
return template.HTML("[error: csrf token not found in request context]")
} }
} }

View File

@ -2,6 +2,7 @@ package responses
import ( import (
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
@ -14,7 +15,7 @@ import (
) )
// NewTemplateVars creates the TemplateVars for your current request. // 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 var s = settings.Current
user, _ := authentication.CurrentUser(r) user, _ := authentication.CurrentUser(r)
@ -33,8 +34,13 @@ func NewTemplateVars(w http.ResponseWriter, r *http.Request) TemplateValues {
IsAdmin: user.IsAdmin, IsAdmin: user.IsAdmin,
CurrentUser: user, 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 return v
} }
@ -68,7 +74,7 @@ type TemplateValues struct {
Flashes []string Flashes []string
// Arbitrary controller-specific fields go in V. // Arbitrary controller-specific fields go in V.
V interface{} V map[string]interface{}
} }
// Flash adds a message to flash on the next template render. // Flash adds a message to flash on the next template render.

View File

@ -2,14 +2,16 @@ package responses
import ( import (
"html/template" "html/template"
"io"
"net/http" "net/http"
"git.kirsle.net/apps/gophertype/pkg/console" "git.kirsle.net/apps/gophertype/pkg/console"
"git.kirsle.net/apps/gophertype/pkg/markdown"
) )
// RenderTemplate renders a Go HTML template. // RenderTemplate renders a Go HTML template.
// The io.Writer can be an http.ResponseWriter. // 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 { if vars == nil {
vars = NewTemplateVars(w, r) 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()) Panic(w, http.StatusInternalServerError, err.Error())
return nil 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
}

View File

@ -8,4 +8,5 @@ const (
SessionKey Key = iota // The request's cookie session object. SessionKey Key = iota // The request's cookie session object.
UserKey // The request's user data for logged-in user. UserKey // The request's user data for logged-in user.
StartTimeKey // The start time of the request. StartTimeKey // The start time of the request.
CSRFKey // CSRF token
) )

View File

@ -95,9 +95,10 @@ func SetFilename(userRoot string) error {
// Load gets or creates the App Settings. // Load gets or creates the App Settings.
func Load() Spec { func Load() Spec {
var s = Spec{ var s = Spec{
Title: "Untitled Site", Title: "Untitled Site",
Description: "Just another web blog.", Description: "Just another web blog.",
SecretKey: MakeSecretKey(), SecretKey: MakeSecretKey(),
PostsPerPage: 20,
} }
session.SetSecretKey([]byte(s.SecretKey)) session.SetSecretKey([]byte(s.SecretKey))

View File

@ -121,6 +121,7 @@
<li class="list-item"><a href="/blog/edit">Post Blog Entry</a></li> <li class="list-item"><a href="/blog/edit">Post Blog Entry</a></li>
<li class="list-item"><a href="/blog/drafts">View Drafts</a></li> <li class="list-item"><a href="/blog/drafts">View Drafts</a></li>
<li class="list-item"><a href="/blog/private">View Private</a></li> <li class="list-item"><a href="/blog/private">View Private</a></li>
<li class="list-item"><a href="/blog/unlisted">View Unlisted</a></li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -0,0 +1,133 @@
{{ define "title" }}Update Blog{{ end }}
{{ define "content" }}
<h1>Update Blog</h1>
{{ if .V.preview }}
<div class="card mb-4">
<div class="card-header">
Preview
</div>
<div class="card-body">
{{ .V.preview }}
</div>
</div>
{{ end }}
{{ $Post := .V.post }}
<form method="POST" action="/blog/edit">
{{ CSRF }}
<input type="hidden" name="id" value="{{ $Post.ID }}">
<div class="card mb-4">
<div class="card-body">
<div class="form-group">
<label for="title">Title</label>
<input type="text" class="form-control"
name="title" id="title"
value="{{ $Post.Title }}"
placeholder="Subject">
</div>
<div class="form-group">
<label for="fragment">URL Fragment</label>
<input type="text" class="form-control"
name="fragment" id="fragment"
aria-describedby="fragment-help"
value="{{ $Post.Fragment }}"
placeholder="url-fragment-for-blog-entry">
<small id="fragment-help" class="form-text text-muted">
You can leave this blank if writing a new post; it will automatically
get a unique fragment based on the post title.
</small>
</div>
<div class="form-group">
<div class="float-right">
<label>
<input type="radio" name="content-type" value="markdown"{{ if ne $Post.ContentType "html" }} checked{{ end }}>
Markdown
</label>
<label>
<input type="radio" name="content-type" value="html"{{ if eq $Post.ContentType "html" }} checked{{ end }}>
HTML
</label>
</div>
<label for="body">Body</label>
<textarea class="form-control" cols="40" rows="12" name="body">{{ $Post.Body }}</textarea>
</div>
<div class="form-group">
<label for="tags">Tags</label>
<input type="text" class="form-control"
name="tags" id="tags"
value="{{ $Post.TagsString }}"
placeholder="comma, separated, list">
</div>
<div class="form-group">
<label for="privacy">Privacy</label>
<select class="form-control"
name="privacy" id="privacy">
<option value="public"{{ if eq $Post.Privacy "public" }} selected{{ end }}>
Public: everyone can see this post</option>
<option value="private"{{ if eq $Post.Privacy "private" }} selected{{ end }}>
Private: only logged-in users can see this post</option>
<option value="unlisted"{{ if eq $Post.Privacy "unlisted" }} selected{{ end }}>
Unlisted: only logged-in users and those with the direct link can see this post</option>
<option value="draft"{{ if eq $Post.Privacy "draft" }} selected{{ end }}>
Draft: only you can see this post</option>
</select>
</div>
<div class="form-group">
<label>Options</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-input-check"
name="sticky" id="sticky"
value="true"
{{ if $Post.Sticky }} checked{{ end }}>
<label class="check-form-label" for="sticky">
Make this post sticky (always on top)
</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-input-check"
name="enable-comments" id="enable-comments"
value="true"
{{ if $Post.EnableComments }} checked{{ end }}>
<label class="check-form-label" for="enable-comments">
Enable comments on this post
</label>
</div>
{{ if not .V.isNew }}
<div class="form-check">
<input type="checkbox" class="form-input-check"
name="no-update" id="no-update"
value="true"
{{ if eq (FormValue "no-update") "true" }} checked{{ end }}>
<label class="check-form-label" for="no-update">
<strong>Editing:</strong> do not update the "modified time" of this post.
</label>
</div>
{{ end }}
<div class="form-group">
<button type="submit" class="btn btn-success"
name="submit" value="preview">
Preview
</button>
<button type="submit" class="btn btn-primary"
name="submit" value="post">
Publish
</button>
</div>
</div>
</div>
</form>
{{ end }}

View File

@ -0,0 +1,8 @@
{{ define "title" }}{{ or .V.title "Blog" }}{{ end }}
{{ define "content" }}
<h1>{{ or .V.title "Blog" }}</h1>
{{ BlogIndex .Request .V.tag .V.privacy }}
{{ end }}

View File

@ -0,0 +1,77 @@
{{ range $i, $Post := .V.posts }}
{{ if gt $i 0 }}<hr class="mb-4">{{ end }}
<div class="card mb-4">
<div class="card-header">
<h1>
<a href="/{{ $Post.Fragment }}" class="blog-title">{{ $Post.Title }}</a>
</h1>
</div>
<div class="card-body">
<small class="text-muted blog-meta"><em>
{{ if $Post.Sticky }}<span class="blog-sticky">[sticky]</span>{{ end }}
{{ if ne $Post.Privacy "public" }}
<span class="blog-{{ $Post.Privacy }}">[{{ $Post.Privacy }}]</span>
{{ end }}
<span title="{{ $Post.CreatedAt.Format "Jan 2 2006 15:04:05 MST" }}">
{{ $Post.CreatedAt.Format "January 2, 2006" }}
</span>
{{ if ($Post.UpdatedAt.After $Post.CreatedAt) }}
<span title="{{ $Post.UpdatedAt.Format "Jan 2 2006 15:04:05 MST" }}">
(updated {{ $Post.UpdatedAt.Format "January 2, 2006" }})
</span>
{{ end }}
{{ if $Post.Author.Name }}
by {{ $Post.Author.Name }}
{{ end }}
</em></small>
<br><br>
{{ $Post.PreviewHTML }}
<div class="mt-4">
<small class="text-muted"><em>
Tags:
{{ range $tag := $Post.Tags }}
<a href="/tagged/{{ $tag.Tag }}" class="ml-2">#{{ $tag.Tag }}</a>
{{ end }}
</em></small>
</div>
</div>
</div>
{{ if $.CurrentUser.IsAdmin }}
<div class="alert alert-secondary">
<small>
<strong>Admin:</strong>
[
<a href="/blog/edit?id={{ $Post.ID }}">edit</a> |
<a href="/blog/delete?id={{ $Post.ID }}">delete</a>
]
</small>
</div>
{{ end }}
{{ end }}
{{ if .V.paging }}
<div class="row">
<div class="col">
<span class="badge badge-secondary" title="{{ .V.paging.Total }} total posts">
Page {{ .V.paging.Page }} of {{ .V.paging.Pages }}
</span>
</div>
<div class="col text-right">
{{ if .V.paging.PreviousPage }}
<a href="?page={{ .V.paging.PreviousPage }}" class="btn btn-sm btn-light">Newer posts</a>
{{ end }}
{{ if .V.paging.NextPage }}
<a href="?page={{ .V.paging.NextPage }}" class="btn btn-sm btn-primary">Older posts</a>
{{ end }}
</div>
</div>
{{ end }}

View File

@ -0,0 +1,56 @@
{{ define "title" }}{{ .V.post.Title }}{{ end }}
{{ define "content" }}
{{ $Post := .V.post }}
<div class="card mb-4">
<div class="card-header">
<h1 class="blog-title">{{ $Post.Title }}</h1>
</div>
<div class="card-body">
<small class="text-muted blog-meta"><em>
{{ if $Post.Sticky }}<span class="blog-sticky">[sticky]</span>{{ end }}
{{ if ne $Post.Privacy "public" }}
<span class="blog-{{ $Post.Privacy }}">[{{ $Post.Privacy }}]</span>
{{ end }}
<span title="{{ $Post.CreatedAt.Format "Jan 2 2006 15:04:05 MST" }}">
{{ $Post.CreatedAt.Format "January 2, 2006" }}
</span>
{{ if ($Post.UpdatedAt.After $Post.CreatedAt) }}
<span title="{{ $Post.UpdatedAt.Format "Jan 2 2006 15:04:05 MST" }}">
(updated {{ $Post.UpdatedAt.Format "January 2, 2006" }})
</span>
{{ end }}
{{ if $Post.Author.Name }}
by {{ $Post.Author.Name }}
{{ end }}
</em></small>
<br><br>
{{ $Post.HTML }}
<div class="mt-4">
<small class="text-muted"><em>
Tags:
{{ range $tag := $Post.Tags }}
<a href="/tagged/{{ $tag.Tag }}" class="ml-2">#{{ $tag.Tag }}</a>
{{ end }}
</em></small>
</div>
</div>
</div>
{{ if .CurrentUser.IsAdmin }}
<div class="alert alert-secondary">
<strong>Admin:</strong>
[
<a href="/blog/edit?id={{ $Post.ID }}">edit</a> |
<a href="/blog/delete?id={{ $Post.ID }}">delete</a>
]
</div>
{{ end }}
{{ end }}

View File

@ -0,0 +1,6 @@
{{ define "title" }}{{ or .V.title "Untitled Markdown Document" }}{{ end }}
{{ define "content" }}
{{ .V.markdown }}
{{ end }}

View File

@ -6,4 +6,7 @@
This is your index page. You can edit it and put whatever you want here. 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. By default, the blog index is also embedded on the website's index page.
</p> </p>
{{ BlogIndex .Request "" "public" }}
{{ end }} {{ end }}