Basic Blog Functionality & Permissions
parent
0b04dad045
commit
117542b23c
|
@ -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
2
go.mod
|
@ -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
11
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 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=
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
)
|
|
@ -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{})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{{ define "title" }}{{ or .V.title "Untitled Markdown Document" }}{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
|
||||||
|
{{ .V.markdown }}
|
||||||
|
|
||||||
|
{{ end }}
|
|
@ -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 }}
|
||||||
|
|
Loading…
Reference in New Issue