Blog post creation, viewing, index listing, editing, deleting
This commit is contained in:
parent
5009065480
commit
b127c61dd7
6
TODO.md
Normal file
6
TODO.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
# To Do
|
||||||
|
|
||||||
|
These aren't high priority but are needed to get this blog on par with Rophako:
|
||||||
|
|
||||||
|
* [ ] On a single blog entry view page, show links to the previous and next
|
||||||
|
blog entry in the header and footer.
|
11
core/app.go
11
core/app.go
|
@ -7,8 +7,10 @@ import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/kirsle/blog/core/jsondb"
|
"github.com/kirsle/blog/core/jsondb"
|
||||||
|
"github.com/kirsle/blog/core/models/posts"
|
||||||
"github.com/kirsle/blog/core/models/settings"
|
"github.com/kirsle/blog/core/models/settings"
|
||||||
"github.com/kirsle/blog/core/models/users"
|
"github.com/kirsle/blog/core/models/users"
|
||||||
|
"github.com/shurcooL/github_flavored_markdown/gfmstyle"
|
||||||
"github.com/urfave/negroni"
|
"github.com/urfave/negroni"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -23,6 +25,9 @@ type Blog struct {
|
||||||
|
|
||||||
DB *jsondb.DB
|
DB *jsondb.DB
|
||||||
|
|
||||||
|
// Helper singletone
|
||||||
|
Posts *PostHelper
|
||||||
|
|
||||||
// Web app objects.
|
// Web app objects.
|
||||||
n *negroni.Negroni // Negroni middleware manager
|
n *negroni.Negroni // Negroni middleware manager
|
||||||
r *mux.Router // Router
|
r *mux.Router // Router
|
||||||
|
@ -36,6 +41,7 @@ func New(documentRoot, userRoot string) *Blog {
|
||||||
UserRoot: userRoot,
|
UserRoot: userRoot,
|
||||||
DB: jsondb.New(filepath.Join(userRoot, ".private")),
|
DB: jsondb.New(filepath.Join(userRoot, ".private")),
|
||||||
}
|
}
|
||||||
|
blog.Posts = InitPostHelper(blog)
|
||||||
|
|
||||||
// Load the site config, or start with defaults if not found.
|
// Load the site config, or start with defaults if not found.
|
||||||
settings.DB = blog.DB
|
settings.DB = blog.DB
|
||||||
|
@ -49,6 +55,7 @@ func New(documentRoot, userRoot string) *Blog {
|
||||||
users.HashCost = config.Security.HashCost
|
users.HashCost = config.Security.HashCost
|
||||||
|
|
||||||
// Initialize the rest of the models.
|
// Initialize the rest of the models.
|
||||||
|
posts.DB = blog.DB
|
||||||
users.DB = blog.DB
|
users.DB = blog.DB
|
||||||
|
|
||||||
// Initialize the router.
|
// Initialize the router.
|
||||||
|
@ -59,6 +66,9 @@ func New(documentRoot, userRoot string) *Blog {
|
||||||
blog.AdminRoutes(r)
|
blog.AdminRoutes(r)
|
||||||
blog.BlogRoutes(r)
|
blog.BlogRoutes(r)
|
||||||
|
|
||||||
|
// GitHub Flavored Markdown CSS.
|
||||||
|
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))
|
||||||
|
|
||||||
r.PathPrefix("/").HandlerFunc(blog.PageHandler)
|
r.PathPrefix("/").HandlerFunc(blog.PageHandler)
|
||||||
r.NotFoundHandler = http.HandlerFunc(blog.PageHandler)
|
r.NotFoundHandler = http.HandlerFunc(blog.PageHandler)
|
||||||
|
|
||||||
|
@ -66,6 +76,7 @@ func New(documentRoot, userRoot string) *Blog {
|
||||||
negroni.NewRecovery(),
|
negroni.NewRecovery(),
|
||||||
negroni.NewLogger(),
|
negroni.NewLogger(),
|
||||||
negroni.HandlerFunc(blog.SessionLoader),
|
negroni.HandlerFunc(blog.SessionLoader),
|
||||||
|
negroni.HandlerFunc(blog.CSRFMiddleware),
|
||||||
negroni.HandlerFunc(blog.AuthMiddleware),
|
negroni.HandlerFunc(blog.AuthMiddleware),
|
||||||
)
|
)
|
||||||
n.UseHandler(r)
|
n.UseHandler(r)
|
||||||
|
|
249
core/blog.go
249
core/blog.go
|
@ -1,19 +1,38 @@
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/kirsle/blog/core/models/posts"
|
"github.com/kirsle/blog/core/models/posts"
|
||||||
|
"github.com/kirsle/blog/core/models/users"
|
||||||
"github.com/urfave/negroni"
|
"github.com/urfave/negroni"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PostMeta associates a Post with injected metadata.
|
||||||
|
type PostMeta struct {
|
||||||
|
Post *posts.Post
|
||||||
|
Rendered template.HTML
|
||||||
|
Author *users.User
|
||||||
|
IndexView bool
|
||||||
|
Snipped bool
|
||||||
|
}
|
||||||
|
|
||||||
// BlogRoutes attaches the blog routes to the app.
|
// BlogRoutes attaches the blog routes to the app.
|
||||||
func (b *Blog) BlogRoutes(r *mux.Router) {
|
func (b *Blog) BlogRoutes(r *mux.Router) {
|
||||||
|
// Public routes
|
||||||
|
r.HandleFunc("/blog", b.BlogIndex)
|
||||||
|
|
||||||
// Login-required routers.
|
// Login-required routers.
|
||||||
loginRouter := mux.NewRouter()
|
loginRouter := mux.NewRouter()
|
||||||
loginRouter.HandleFunc("/blog/edit", b.EditBlog)
|
loginRouter.HandleFunc("/blog/edit", b.EditBlog)
|
||||||
|
loginRouter.HandleFunc("/blog/delete", b.DeletePost)
|
||||||
r.PathPrefix("/blog").Handler(
|
r.PathPrefix("/blog").Handler(
|
||||||
negroni.New(
|
negroni.New(
|
||||||
negroni.HandlerFunc(b.LoginRequired),
|
negroni.HandlerFunc(b.LoginRequired),
|
||||||
|
@ -31,24 +50,208 @@ func (b *Blog) BlogRoutes(r *mux.Router) {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlogIndex renders the main index page of the blog.
|
||||||
|
func (b *Blog) BlogIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v := NewVars(map[interface{}]interface{}{})
|
||||||
|
|
||||||
|
// Get the blog index.
|
||||||
|
idx, _ := posts.GetIndex()
|
||||||
|
|
||||||
|
// The set of blog posts to show.
|
||||||
|
var pool []posts.Post
|
||||||
|
for _, post := range idx.Posts {
|
||||||
|
pool = append(pool, post)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(sort.Reverse(posts.ByUpdated(pool)))
|
||||||
|
|
||||||
|
// Query parameters.
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
perPage := 5 // TODO: configurable
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
stop := offset + perPage
|
||||||
|
|
||||||
|
// Handle pagination.
|
||||||
|
v.Data["Page"] = page
|
||||||
|
if page > 1 {
|
||||||
|
v.Data["PreviousPage"] = page - 1
|
||||||
|
} else {
|
||||||
|
v.Data["PreviousPage"] = 0
|
||||||
|
}
|
||||||
|
if offset+perPage < len(pool) {
|
||||||
|
v.Data["NextPage"] = page + 1
|
||||||
|
} else {
|
||||||
|
v.Data["NextPage"] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var view []PostMeta
|
||||||
|
for i := offset; i < stop; i++ {
|
||||||
|
if i >= len(pool) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
post, err := posts.Load(pool[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("couldn't load full post data for ID %d (found in index.json)", pool[i].ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rendered template.HTML
|
||||||
|
|
||||||
|
// Body has a snipped section?
|
||||||
|
if strings.Contains(post.Body, "<snip>") {
|
||||||
|
parts := strings.SplitN(post.Body, "<snip>", 1)
|
||||||
|
post.Body = parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the post.
|
||||||
|
if post.ContentType == "markdown" {
|
||||||
|
rendered = template.HTML(b.RenderTrustedMarkdown(post.Body))
|
||||||
|
} else {
|
||||||
|
rendered = template.HTML(post.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the author's information.
|
||||||
|
author, err := users.LoadReadonly(post.AuthorID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to look up post author ID %d (post %d): %v", post.AuthorID, post.ID, err)
|
||||||
|
author = users.DeletedUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
view = append(view, PostMeta{
|
||||||
|
Post: post,
|
||||||
|
Rendered: rendered,
|
||||||
|
Author: author,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Data["View"] = view
|
||||||
|
b.RenderTemplate(w, r, "blog/index", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewPost is the underlying implementation of the handler to view a blog
|
||||||
|
// post, so that it can be called from non-http.HandlerFunc contexts.
|
||||||
|
func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string) error {
|
||||||
|
post, err := posts.LoadFragment(fragment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
v := NewVars(map[interface{}]interface{}{
|
||||||
|
"Post": post,
|
||||||
|
})
|
||||||
|
b.RenderTemplate(w, r, "blog/entry", v)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPost renders a blog post as a partial template and returns the HTML.
|
||||||
|
// If indexView is true, the blog headers will be hyperlinked to the dedicated
|
||||||
|
// entry view page.
|
||||||
|
func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
|
||||||
|
// Look up the author's information.
|
||||||
|
author, err := users.LoadReadonly(p.AuthorID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to look up post author ID %d (post %d): %v", p.AuthorID, p.ID, err)
|
||||||
|
author = users.DeletedUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Read More" snippet for index views.
|
||||||
|
var snipped bool
|
||||||
|
if indexView {
|
||||||
|
if strings.Contains(p.Body, "<snip>") {
|
||||||
|
log.Warn("HAS SNIP TAG!")
|
||||||
|
parts := strings.SplitN(p.Body, "<snip>", 2)
|
||||||
|
p.Body = parts[0]
|
||||||
|
snipped = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the post to HTML.
|
||||||
|
var rendered template.HTML
|
||||||
|
if p.ContentType == "markdown" {
|
||||||
|
rendered = template.HTML(b.RenderTrustedMarkdown(p.Body))
|
||||||
|
} else {
|
||||||
|
rendered = template.HTML(p.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the template snippet.
|
||||||
|
filepath, err := b.ResolvePath("blog/entry.partial")
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err.Error())
|
||||||
|
return "[error: missing blog/entry.partial]"
|
||||||
|
}
|
||||||
|
t := template.New("entry.partial.gohtml")
|
||||||
|
t, err = t.ParseFiles(filepath.Absolute)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to parse entry.partial: %s", err.Error())
|
||||||
|
return "[error parsing template in blog/entry.partial]"
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := PostMeta{
|
||||||
|
Post: p,
|
||||||
|
Rendered: rendered,
|
||||||
|
Author: author,
|
||||||
|
IndexView: indexView,
|
||||||
|
Snipped: snipped,
|
||||||
|
}
|
||||||
|
output := bytes.Buffer{}
|
||||||
|
err = t.Execute(&output, meta)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err.Error())
|
||||||
|
return "[error executing template in blog/entry.partial]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.HTML(output.String())
|
||||||
|
}
|
||||||
|
|
||||||
// EditBlog is the blog writing and editing page.
|
// EditBlog is the blog writing and editing page.
|
||||||
func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) {
|
func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) {
|
||||||
v := NewVars(map[interface{}]interface{}{
|
v := NewVars(map[interface{}]interface{}{
|
||||||
"preview": "",
|
"preview": "",
|
||||||
})
|
})
|
||||||
post := posts.New()
|
var post *posts.Post
|
||||||
|
|
||||||
|
// Are we editing an existing post?
|
||||||
|
if idStr := r.URL.Query().Get("id"); idStr != "" {
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err == nil {
|
||||||
|
post, err = posts.Load(id)
|
||||||
|
if err != nil {
|
||||||
|
v.Error = errors.New("that post ID was not found")
|
||||||
|
post = posts.New()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
post = posts.New()
|
||||||
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
// Parse from form values.
|
// Parse from form values.
|
||||||
post.LoadForm(r)
|
post.ParseForm(r)
|
||||||
|
|
||||||
// Previewing, or submitting?
|
// Previewing, or submitting?
|
||||||
switch r.FormValue("submit") {
|
switch r.FormValue("submit") {
|
||||||
case "preview":
|
case "preview":
|
||||||
v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body))
|
if post.ContentType == "markdown" || post.ContentType == "markdown+html" {
|
||||||
case "submit":
|
v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body))
|
||||||
|
} else {
|
||||||
|
v.Data["preview"] = template.HTML(post.Body)
|
||||||
|
}
|
||||||
|
case "post":
|
||||||
if err := post.Validate(); err != nil {
|
if err := post.Validate(); err != nil {
|
||||||
v.Error = err
|
v.Error = err
|
||||||
|
} else {
|
||||||
|
author, _ := b.CurrentUser(r)
|
||||||
|
post.AuthorID = author.ID
|
||||||
|
err = post.Save()
|
||||||
|
if err != nil {
|
||||||
|
v.Error = err
|
||||||
|
} else {
|
||||||
|
b.Flash(w, r, "Post created!")
|
||||||
|
b.Redirect(w, "/"+post.Fragment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,3 +259,41 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) {
|
||||||
v.Data["post"] = post
|
v.Data["post"] = post
|
||||||
b.RenderTemplate(w, r, "blog/edit", v)
|
b.RenderTemplate(w, r, "blog/edit", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletePost to delete a blog entry.
|
||||||
|
func (b *Blog) DeletePost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var post *posts.Post
|
||||||
|
v := NewVars(map[interface{}]interface{}{
|
||||||
|
"Post": nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
var idStr string
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
idStr = r.FormValue("id")
|
||||||
|
} else {
|
||||||
|
idStr = r.URL.Query().Get("id")
|
||||||
|
}
|
||||||
|
if idStr == "" {
|
||||||
|
b.FlashAndRedirect(w, r, "/admin", "No post ID given for deletion!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the post ID to an int.
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err == nil {
|
||||||
|
post, err = posts.Load(id)
|
||||||
|
if err != nil {
|
||||||
|
b.FlashAndRedirect(w, r, "/admin", "That post ID was not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
post.Delete()
|
||||||
|
b.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Data["Post"] = post
|
||||||
|
b.RenderTemplate(w, r, "blog/delete", v)
|
||||||
|
}
|
||||||
|
|
|
@ -141,7 +141,7 @@ func (db *DB) list(path string, recursive bool) ([]string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(filePath, ".json") {
|
if strings.HasSuffix(filePath, ".json") {
|
||||||
name := strings.TrimSuffix(filePath, ".json")
|
name := strings.TrimSuffix(dbPath, ".json")
|
||||||
docs = append(docs, name)
|
docs = append(docs, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,28 @@
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import "github.com/shurcooL/github_flavored_markdown"
|
import (
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
"github.com/shurcooL/github_flavored_markdown"
|
||||||
|
)
|
||||||
|
|
||||||
// RenderMarkdown renders markdown to HTML.
|
// RenderMarkdown renders markdown to HTML, safely. It uses blackfriday to
|
||||||
|
// render Markdown to HTML and then Bluemonday to sanitize the resulting HTML.
|
||||||
func (b *Blog) RenderMarkdown(input string) string {
|
func (b *Blog) RenderMarkdown(input string) string {
|
||||||
output := github_flavored_markdown.Markdown([]byte(input))
|
unsafe := []byte(b.RenderTrustedMarkdown(input))
|
||||||
return string(output)
|
|
||||||
|
// 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 (b *Blog) RenderTrustedMarkdown(input string) string {
|
||||||
|
html := github_flavored_markdown.Markdown([]byte(input))
|
||||||
|
log.Info("%s", html)
|
||||||
|
return string(html)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,10 @@ package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/kirsle/blog/core/models/users"
|
"github.com/kirsle/blog/core/models/users"
|
||||||
)
|
)
|
||||||
|
@ -40,25 +42,54 @@ func (b *Blog) Session(r *http.Request) *sessions.Session {
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthMiddleware loads the user's authentication state.
|
// CSRFMiddleware enforces CSRF tokens on all POST requests.
|
||||||
func (b *Blog) AuthMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
func (b *Blog) CSRFMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||||
session := b.Session(r)
|
if r.Method == "POST" {
|
||||||
log.Debug("AuthMiddleware() -- session values: %v", session.Values)
|
session := b.Session(r)
|
||||||
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
token, ok := session.Values["csrf"].(string)
|
||||||
// They seem to be logged in. Get their user object.
|
if !ok || token != r.FormValue("_csrf") {
|
||||||
id := session.Values["user-id"].(int)
|
b.Forbidden(w, r, "Failed to validate CSRF token. Please try your request again.")
|
||||||
u, err := users.Load(id)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error loading user ID %d from session: %v", id, err)
|
|
||||||
next(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), userKey, u)
|
next(w, r)
|
||||||
next(w, r.WithContext(ctx))
|
}
|
||||||
|
|
||||||
|
// GenerateCSRFToken generates a CSRF token for the user and puts it in their session.
|
||||||
|
func (b *Blog) GenerateCSRFToken(w http.ResponseWriter, r *http.Request, session *sessions.Session) string {
|
||||||
|
token, ok := session.Values["csrf"].(string)
|
||||||
|
if !ok {
|
||||||
|
token := uuid.New()
|
||||||
|
session.Values["csrf"] = token.String()
|
||||||
|
session.Save(r, w)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentUser returns the current user's object.
|
||||||
|
func (b *Blog) CurrentUser(r *http.Request) (*users.User, error) {
|
||||||
|
session := b.Session(r)
|
||||||
|
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
||||||
|
id := session.Values["user-id"].(int)
|
||||||
|
u, err := users.LoadReadonly(id)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &users.User{}, errors.New("not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware loads the user's authentication state.
|
||||||
|
func (b *Blog) AuthMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||||
|
u, err := b.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error loading user from session: %v", err)
|
||||||
|
next(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r)
|
|
||||||
|
ctx := context.WithValue(r.Context(), userKey, u)
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginRequired is a middleware that requires a logged-in user.
|
// LoginRequired is a middleware that requires a logged-in user.
|
||||||
|
|
100
core/models/posts/index.go
Normal file
100
core/models/posts/index.go
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
package posts
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// UpdateIndex updates a post's metadata in the blog index.
|
||||||
|
func UpdateIndex(p *Post) error {
|
||||||
|
idx, err := GetIndex()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return idx.Update(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index caches high level metadata about the blog's contents for fast access.
|
||||||
|
type Index struct {
|
||||||
|
Posts map[int]Post `json:"posts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIndex loads the index, or rebuilds it first if it doesn't exist.
|
||||||
|
func GetIndex() (*Index, error) {
|
||||||
|
if !DB.Exists("blog/index") {
|
||||||
|
index, err := RebuildIndex()
|
||||||
|
return index, err
|
||||||
|
}
|
||||||
|
idx := &Index{}
|
||||||
|
err := DB.Get("blog/index", &idx)
|
||||||
|
return idx, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildIndex builds the index from scratch.
|
||||||
|
func RebuildIndex() (*Index, error) {
|
||||||
|
idx := &Index{
|
||||||
|
Posts: map[int]Post{},
|
||||||
|
}
|
||||||
|
entries, _ := DB.List("blog/posts")
|
||||||
|
for _, doc := range entries {
|
||||||
|
p := &Post{}
|
||||||
|
err := DB.Get(doc, &p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.Update(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a blog's entry in the index.
|
||||||
|
func (idx *Index) Update(p *Post) error {
|
||||||
|
idx.Posts[p.ID] = Post{
|
||||||
|
ID: p.ID,
|
||||||
|
Title: p.Title,
|
||||||
|
Fragment: p.Fragment,
|
||||||
|
AuthorID: p.AuthorID,
|
||||||
|
Privacy: p.Privacy,
|
||||||
|
Tags: p.Tags,
|
||||||
|
Created: p.Created,
|
||||||
|
Updated: p.Updated,
|
||||||
|
}
|
||||||
|
err := DB.Commit("blog/index", idx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a blog's entry from the index.
|
||||||
|
func (idx *Index) Delete(p *Post) error {
|
||||||
|
delete(idx.Posts, p.ID)
|
||||||
|
return DB.Commit("blog/index", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupFragments to clean up old URL fragments.
|
||||||
|
func CleanupFragments() error {
|
||||||
|
idx, err := GetIndex()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return idx.CleanupFragments()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupFragments to clean up old URL fragments.
|
||||||
|
func (idx *Index) CleanupFragments() error {
|
||||||
|
// Keep track of the active URL fragments so we can clean up orphans.
|
||||||
|
fragments := map[string]struct{}{}
|
||||||
|
for _, p := range idx.Posts {
|
||||||
|
fragments[p.Fragment] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up unused fragments.
|
||||||
|
byFragment, err := DB.List("blog/fragments")
|
||||||
|
for _, doc := range byFragment {
|
||||||
|
parts := strings.Split(doc, "/")
|
||||||
|
fragment := parts[len(parts)-1]
|
||||||
|
if _, ok := fragments[fragment]; !ok {
|
||||||
|
log.Debug("RebuildIndex() clean up old fragment '%s'", fragment)
|
||||||
|
DB.Delete(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
|
@ -2,22 +2,45 @@ package posts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kirsle/blog/core/jsondb"
|
||||||
|
"github.com/kirsle/golog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DB is a reference to the parent app's JsonDB object.
|
||||||
|
var DB *jsondb.DB
|
||||||
|
|
||||||
|
var log *golog.Logger
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log = golog.GetLogger("blog")
|
||||||
|
}
|
||||||
|
|
||||||
// Post holds information for a blog post.
|
// Post holds information for a blog post.
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Fragment string `json:"fragment"`
|
Fragment string `json:"fragment"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
Body string `json:"body"`
|
AuthorID int `json:"author"`
|
||||||
Privacy string `json:"privacy"`
|
Body string `json:"body,omitempty"`
|
||||||
Sticky bool `json:"sticky"`
|
Privacy string `json:"privacy"`
|
||||||
EnableComments bool `json:"enableComments"`
|
Sticky bool `json:"sticky"`
|
||||||
Tags []string `json:"tags"`
|
EnableComments bool `json:"enableComments"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByFragment maps a blog post by its URL fragment.
|
||||||
|
type ByFragment struct {
|
||||||
|
ID int `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a blank post with sensible defaults.
|
// New creates a blank post with sensible defaults.
|
||||||
|
@ -29,8 +52,8 @@ func New() *Post {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadForm populates the post from form values.
|
// ParseForm populates the post from form values.
|
||||||
func (p *Post) LoadForm(r *http.Request) {
|
func (p *Post) ParseForm(r *http.Request) {
|
||||||
id, _ := strconv.Atoi(r.FormValue("id"))
|
id, _ := strconv.Atoi(r.FormValue("id"))
|
||||||
|
|
||||||
p.ID = id
|
p.ID = id
|
||||||
|
@ -64,3 +87,139 @@ func (p *Post) Validate() error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load a post by its ID.
|
||||||
|
func Load(id int) (*Post, error) {
|
||||||
|
p := &Post{}
|
||||||
|
err := DB.Get(fmt.Sprintf("blog/posts/%d", id), &p)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFragment loads a blog entry by its URL fragment.
|
||||||
|
func LoadFragment(fragment string) (*Post, error) {
|
||||||
|
f := ByFragment{}
|
||||||
|
err := DB.Get("blog/fragments/"+fragment, &f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := Load(f.ID)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the blog post.
|
||||||
|
func (p *Post) Save() error {
|
||||||
|
// Editing an existing post?
|
||||||
|
if p.ID == 0 {
|
||||||
|
p.ID = p.nextID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a URL fragment if needed.
|
||||||
|
if p.Fragment == "" {
|
||||||
|
fragment := strings.ToLower(p.Title)
|
||||||
|
fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-")
|
||||||
|
if strings.Contains(fragment, "--") {
|
||||||
|
log.Error("Generated blog fragment '%s' contains double dashes still!", fragment)
|
||||||
|
}
|
||||||
|
p.Fragment = strings.Trim(fragment, "-")
|
||||||
|
|
||||||
|
// If still no fragment, make one based on the post ID.
|
||||||
|
if p.Fragment == "" {
|
||||||
|
p.Fragment = fmt.Sprintf("post-%d", p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the URL fragment is unique!
|
||||||
|
if len(p.Fragment) > 0 {
|
||||||
|
if exist, err := LoadFragment(p.Fragment); err == nil && exist.ID != p.ID {
|
||||||
|
var resolved bool
|
||||||
|
for i := 1; i <= 100; i++ {
|
||||||
|
fragment := fmt.Sprintf("%s-%d", p.Fragment, i)
|
||||||
|
_, err := 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dates & times.
|
||||||
|
if p.Created.IsZero() {
|
||||||
|
p.Created = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if p.Updated.IsZero() {
|
||||||
|
p.Updated = p.Created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty tag lists.
|
||||||
|
if len(p.Tags) == 1 && p.Tags[0] == "" {
|
||||||
|
p.Tags = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the post.
|
||||||
|
DB.Commit(fmt.Sprintf("blog/posts/%d", p.ID), p)
|
||||||
|
DB.Commit(fmt.Sprintf("blog/fragments/%s", p.Fragment), ByFragment{p.ID})
|
||||||
|
|
||||||
|
// Update the index cache.
|
||||||
|
err := UpdateIndex(p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("RebuildIndex() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up fragments.
|
||||||
|
CleanupFragments()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a blog entry.
|
||||||
|
func (p *Post) Delete() error {
|
||||||
|
if p.ID == 0 {
|
||||||
|
return errors.New("post has no ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the DB files.
|
||||||
|
DB.Delete(fmt.Sprintf("blog/posts/%d", p.ID))
|
||||||
|
DB.Delete(fmt.Sprintf("blog/fragments/%s", p.Fragment))
|
||||||
|
|
||||||
|
// Remove it from the index.
|
||||||
|
idx, err := GetIndex()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GetIndex error: %v", err)
|
||||||
|
}
|
||||||
|
return idx.Delete(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNextID gets the next blog post ID.
|
||||||
|
func (p *Post) nextID() int {
|
||||||
|
// Highest ID seen so far.
|
||||||
|
var highest int
|
||||||
|
|
||||||
|
posts, err := DB.List("blog/posts")
|
||||||
|
if err != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, doc := range posts {
|
||||||
|
fields := strings.Split(doc, "/")
|
||||||
|
id, err := strconv.Atoi(fields[len(fields)-1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if id > highest {
|
||||||
|
highest = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the highest +1
|
||||||
|
return highest + 1
|
||||||
|
}
|
||||||
|
|
10
core/models/posts/sorting.go
Normal file
10
core/models/posts/sorting.go
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package posts
|
||||||
|
|
||||||
|
// ByUpdated sorts blog entries by most recently updated.
|
||||||
|
type ByUpdated []Post
|
||||||
|
|
||||||
|
func (a ByUpdated) Len() int { return len(a) }
|
||||||
|
func (a ByUpdated) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||||
|
func (a ByUpdated) Less(i, j int) bool {
|
||||||
|
return a[i].Updated.Before(a[i].Updated) || a[i].ID < a[j].ID
|
||||||
|
}
|
|
@ -25,6 +25,10 @@ type User struct {
|
||||||
Admin bool `json:"admin"`
|
Admin bool `json:"admin"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
||||||
|
// Whether the user was loaded in read-only mode (no password), so they
|
||||||
|
// can't be saved without a password.
|
||||||
|
readonly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ByName model maps usernames to their IDs.
|
// ByName model maps usernames to their IDs.
|
||||||
|
@ -58,6 +62,13 @@ func Create(u *User) error {
|
||||||
return u.Save()
|
return u.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeletedUser returns a User object to represent a deleted (non-existing) user.
|
||||||
|
func DeletedUser() *User {
|
||||||
|
return &User{
|
||||||
|
Username: "[deleted]",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CheckAuth tests a login with a username and password.
|
// CheckAuth tests a login with a username and password.
|
||||||
func CheckAuth(username, password string) (*User, error) {
|
func CheckAuth(username, password string) (*User, error) {
|
||||||
username = Normalize(username)
|
username = Normalize(username)
|
||||||
|
@ -118,8 +129,20 @@ func Load(id int) (*User, error) {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadReadonly loads a user for read-only use, so the Password is masked.
|
||||||
|
func LoadReadonly(id int) (*User, error) {
|
||||||
|
u, err := Load(id)
|
||||||
|
u.Password = ""
|
||||||
|
u.readonly = true
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
// Save the user.
|
// Save the user.
|
||||||
func (u *User) Save() error {
|
func (u *User) Save() error {
|
||||||
|
if u.readonly {
|
||||||
|
return errors.New("user is read-only")
|
||||||
|
}
|
||||||
|
|
||||||
// Sanity check that we have an ID.
|
// Sanity check that we have an ID.
|
||||||
if u.ID == 0 {
|
if u.ID == 0 {
|
||||||
return errors.New("can't save a user with no ID")
|
return errors.New("can't save a user with no ID")
|
||||||
|
|
|
@ -19,6 +19,12 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle the root URI with the blog index.
|
||||||
|
if path == "/" {
|
||||||
|
b.BlogIndex(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Restrict special paths.
|
// Restrict special paths.
|
||||||
if strings.HasPrefix(strings.ToLower(path), "/.") {
|
if strings.HasPrefix(strings.ToLower(path), "/.") {
|
||||||
b.Forbidden(w, r)
|
b.Forbidden(w, r)
|
||||||
|
@ -28,7 +34,11 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// Search for a file that matches their URL.
|
// Search for a file that matches their URL.
|
||||||
filepath, err := b.ResolvePath(path)
|
filepath, err := b.ResolvePath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.NotFound(w, r, "The page you were looking for was not found.")
|
// See if it resolves as a blog entry.
|
||||||
|
err = b.viewPost(w, r, path)
|
||||||
|
if err != nil {
|
||||||
|
b.NotFound(w, r, "The page you were looking for was not found.")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
23
core/postdb.go
Normal file
23
core/postdb.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kirsle/blog/core/jsondb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostHelper is a singleton helper to manage the database controls for blog
|
||||||
|
// entries.
|
||||||
|
type PostHelper struct {
|
||||||
|
master *Blog
|
||||||
|
DB *jsondb.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitPostHelper initializes the blog post controller helper.
|
||||||
|
func InitPostHelper(master *Blog) *PostHelper {
|
||||||
|
return &PostHelper{
|
||||||
|
master: master,
|
||||||
|
DB: master.DB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIndex loads the blog index (cache).
|
||||||
|
func (p *PostHelper) GetIndex() {}
|
8
core/regexp.go
Normal file
8
core/regexp.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// CSS classes for Markdown fenced code blocks
|
||||||
|
reFencedCodeClass = regexp.MustCompile("^highlight highlight-[a-zA-Z0-9]+$")
|
||||||
|
)
|
|
@ -50,9 +50,10 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin
|
||||||
|
|
||||||
// Forbidden sends an HTTP 403 Forbidden response.
|
// Forbidden sends an HTTP 403 Forbidden response.
|
||||||
func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) {
|
func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) {
|
||||||
log.Error("HERE 3")
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
err := b.RenderTemplate(w, r, ".errors/403", nil)
|
err := b.RenderTemplate(w, r, ".errors/403", &Vars{
|
||||||
|
Message: message[0],
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
log.Error(err.Error())
|
||||||
w.Write([]byte("Unrecoverable template error for Forbidden()"))
|
w.Write([]byte("Unrecoverable template error for Forbidden()"))
|
||||||
|
|
|
@ -20,6 +20,8 @@ type Vars struct {
|
||||||
Path string
|
Path string
|
||||||
LoggedIn bool
|
LoggedIn bool
|
||||||
CurrentUser *users.User
|
CurrentUser *users.User
|
||||||
|
CSRF string
|
||||||
|
Request *http.Request
|
||||||
|
|
||||||
// Common template variables.
|
// Common template variables.
|
||||||
Message string
|
Message string
|
||||||
|
@ -54,6 +56,7 @@ func (v *Vars) LoadDefaults(b *Blog, w http.ResponseWriter, r *http.Request) {
|
||||||
if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup") {
|
if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup") {
|
||||||
v.SetupNeeded = true
|
v.SetupNeeded = true
|
||||||
}
|
}
|
||||||
|
v.Request = r
|
||||||
v.Title = s.Site.Title
|
v.Title = s.Site.Title
|
||||||
v.Path = r.URL.Path
|
v.Path = r.URL.Path
|
||||||
|
|
||||||
|
@ -67,6 +70,8 @@ func (v *Vars) LoadDefaults(b *Blog, w http.ResponseWriter, r *http.Request) {
|
||||||
session.Save(r, w)
|
session.Save(r, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
v.CSRF = b.GenerateCSRFToken(w, r, session)
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
if user, ok := ctx.Value(userKey).(*users.User); ok {
|
if user, ok := ctx.Value(userKey).(*users.User); ok {
|
||||||
if user.ID > 0 {
|
if user.ID > 0 {
|
||||||
|
@ -101,6 +106,7 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin
|
||||||
log.Error("HERE!!!")
|
log.Error("HERE!!!")
|
||||||
t := template.New(filepath.Absolute).Funcs(template.FuncMap{
|
t := template.New(filepath.Absolute).Funcs(template.FuncMap{
|
||||||
"StringsJoin": strings.Join,
|
"StringsJoin": strings.Join,
|
||||||
|
"RenderPost": b.RenderPost,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Parse the template files. The layout comes first because it's the wrapper
|
// Parse the template files. The layout comes first because it's the wrapper
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{{ define "title" }}Untitled{{ end }}
|
{{ define "title" }}WTF?{{ end }}
|
||||||
{{ define "scripts" }}{{ end }}
|
{{ define "scripts" }}{{ end }}
|
||||||
|
|
||||||
{{ define "layout" }}
|
{{ define "layout" }}
|
||||||
|
@ -8,11 +8,14 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
<title>{{ template "title" or "Untitled" }} - {{ .Title }}</title>
|
<title>{{ template "title" . }} - {{ .Title }}</title>
|
||||||
|
|
||||||
<!-- Bootstrap core CSS -->
|
<!-- Bootstrap core CSS -->
|
||||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="/bluez/theme.css">
|
<link rel="stylesheet" href="/bluez/theme.css">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/css/blog-core.css">
|
||||||
|
<!-- <link rel="stylesheet" href="/css/gfm.css"> -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{{ define "title" }}Website Settings{{ end }}
|
{{ define "title" }}Website Settings{{ end }}
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<form action="/admin/settings" method="POST">
|
<form action="/admin/settings" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{{ with .Data.s }}
|
{{ with .Data.s }}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
15
root/blog/delete.gohtml
Normal file
15
root/blog/delete.gohtml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{{ define "title" }}Delete Entry{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
<form action="/blog/delete" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
|
<input type="hidden" name="id" value="{{ .Data.Post.ID }}">
|
||||||
|
|
||||||
|
<h1>Delete Post</h1>
|
||||||
|
|
||||||
|
<p>Are you sure you want to delete <strong>{{ .Data.Post.Title }}</strong>?</p>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Delete Post</button>
|
||||||
|
<a href="/{{ .Data.Post.Fragment }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
|
@ -1,9 +1,10 @@
|
||||||
{{ define "title" }}Update Blog{{ end }}
|
{{ define "title" }}Update Blog{{ end }}
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<form action="/blog/edit" method="POST">
|
<form action="/blog/edit" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
{{ if .Data.preview }}
|
{{ if .Data.preview }}
|
||||||
<div class="card">
|
<div class="card mb-5">
|
||||||
<div class="card-title">
|
<div class="card-header">
|
||||||
Preview
|
Preview
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
@ -13,12 +14,11 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ with .Data.post }}
|
{{ with .Data.post }}
|
||||||
|
<input type="hidden" name="id" value="{{ .ID }}">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3>Update Blog</h3>
|
<h3>Update Blog</h3>
|
||||||
|
|
||||||
{{ . }}
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Title</label>
|
<label for="title">Title</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
|
27
root/blog/entry.gohtml
Normal file
27
root/blog/entry.gohtml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{{ define "title" }}{{ .Data.Post.Title }}{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
|
||||||
|
{{ $p := .Data.Post }}
|
||||||
|
{{ RenderPost $p false }}
|
||||||
|
|
||||||
|
{{ if and .LoggedIn .CurrentUser.Admin }}
|
||||||
|
<small>
|
||||||
|
<strong>Admin Actions:</strong>
|
||||||
|
[
|
||||||
|
<a href="/blog/edit?id={{ $p.ID }}">Edit</a> |
|
||||||
|
<a href="/blog/delete?id={{ $p.ID }}">Delete</a>
|
||||||
|
]
|
||||||
|
</small>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if $p.EnableComments }}
|
||||||
|
<h2 class="mt-4">Comments</h2>
|
||||||
|
|
||||||
|
TBD.
|
||||||
|
{{ else }}
|
||||||
|
<hr>
|
||||||
|
<em>Comments are disabled on this post.</em>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
|
||||||
|
{{ end }}
|
41
root/blog/entry.partial.gohtml
Normal file
41
root/blog/entry.partial.gohtml
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
{{ $a := .Author }}
|
||||||
|
{{ $p := .Post }}
|
||||||
|
|
||||||
|
{{ if .IndexView }}
|
||||||
|
<a class="h1 blog-title" href="/{{ $p.Fragment }}">{{ $p.Title }}</a>
|
||||||
|
{{ else }}
|
||||||
|
<h1 class="blog-title">{{ $p.Title }}</h1>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<div class="blog-meta">
|
||||||
|
<span title="{{ $p.Created.Format "Jan 2 2006 @ 15:04:05 MST" }}">
|
||||||
|
{{ $p.Created.Format "January 2, 2006" }}
|
||||||
|
</span>
|
||||||
|
{{ if $p.Updated.After $p.Created }}
|
||||||
|
<span title="{{ $p.Updated.Format "Jan 2 2006 @ 15:04:05 MST" }}">
|
||||||
|
(updated {{ $p.Updated.Format "January 2, 2006" }})
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
by <a href="/u/{{ $a.Username }}">{{ or $a.Name $a.Username }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="markdown mb-4">
|
||||||
|
{{ .Rendered }}
|
||||||
|
|
||||||
|
{{ if .Snipped }}
|
||||||
|
<p>
|
||||||
|
<a href="/{{ $p.Fragment }}#snip">Read more...</a>
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if not .IndexView }}<hr>{{ end }}
|
||||||
|
|
||||||
|
{{ if $p.Tags }}
|
||||||
|
<em class="text-muted float-left pr-3">Tags:</em>
|
||||||
|
<ul class="list-inline">
|
||||||
|
{{ range $p.Tags }}
|
||||||
|
<li class="list-inline-item"><a href="/tagged/{{ . }}">{{ . }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
46
root/blog/index.gohtml
Normal file
46
root/blog/index.gohtml
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
{{ define "title" }}Welcome{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-right">
|
||||||
|
<ul class="list-inline">
|
||||||
|
{{ if .Data.PreviousPage }}
|
||||||
|
<li class="list-inline-item"><a href="/?page={{ .Data.PreviousPage }}">Earlier</a></li>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .Data.NextPage }}
|
||||||
|
<li class="list-inline-item"><a href="/?page={{ .Data.NextPage }}">Older</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ range .Data.View }}
|
||||||
|
{{ $p := .Post }}
|
||||||
|
{{ RenderPost $p true }}
|
||||||
|
|
||||||
|
{{ if and $.LoggedIn $.CurrentUser.Admin }}
|
||||||
|
<div class="mb-4">
|
||||||
|
<small>
|
||||||
|
<strong>Admin Actions:</strong>
|
||||||
|
[
|
||||||
|
<a href="/blog/edit?id={{ $p.ID }}">Edit</a> |
|
||||||
|
<a href="/blog/delete?id={{ $p.ID }}">Delete</a>
|
||||||
|
]
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<hr>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col text-right">
|
||||||
|
<ul class="list-inline">
|
||||||
|
{{ if .Data.PreviousPage }}
|
||||||
|
<li class="list-inline-item"><a href="/?page={{ .Data.PreviousPage }}">Earlier</a></li>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .Data.NextPage }}
|
||||||
|
<li class="list-inline-item"><a href="/?page={{ .Data.NextPage }}">Older</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ end }}
|
24
root/css/blog-core.css
Normal file
24
root/css/blog-core.css
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Generally useful blog styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Styles for the blog title <h1> */
|
||||||
|
.blog-title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
a.blog-title {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The blog entry publish date */
|
||||||
|
.blog-meta {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: smaller;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks treated as <pre> tags */
|
||||||
|
.markdown code {
|
||||||
|
display: block;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
|
@ -14,6 +14,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST" action="/initial-setup">
|
<form method="POST" action="/initial-setup">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="setup-admin-username">Admin username:</label>
|
<label for="setup-admin-username">Admin username:</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
<h1>Sign In</h1>
|
<h1>Sign In</h1>
|
||||||
|
|
||||||
<form name="login" action="/login" method="POST">
|
<form name="login" action="/login" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<input type="text" name="username" class="form-control" placeholder="Username">
|
<input type="text" name="username" class="form-control" placeholder="Username">
|
||||||
|
|
Loading…
Reference in New Issue
Block a user