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/sessions"
|
||||
"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/users"
|
||||
"github.com/shurcooL/github_flavored_markdown/gfmstyle"
|
||||
"github.com/urfave/negroni"
|
||||
)
|
||||
|
||||
|
@ -23,6 +25,9 @@ type Blog struct {
|
|||
|
||||
DB *jsondb.DB
|
||||
|
||||
// Helper singletone
|
||||
Posts *PostHelper
|
||||
|
||||
// Web app objects.
|
||||
n *negroni.Negroni // Negroni middleware manager
|
||||
r *mux.Router // Router
|
||||
|
@ -36,6 +41,7 @@ func New(documentRoot, userRoot string) *Blog {
|
|||
UserRoot: userRoot,
|
||||
DB: jsondb.New(filepath.Join(userRoot, ".private")),
|
||||
}
|
||||
blog.Posts = InitPostHelper(blog)
|
||||
|
||||
// Load the site config, or start with defaults if not found.
|
||||
settings.DB = blog.DB
|
||||
|
@ -49,6 +55,7 @@ func New(documentRoot, userRoot string) *Blog {
|
|||
users.HashCost = config.Security.HashCost
|
||||
|
||||
// Initialize the rest of the models.
|
||||
posts.DB = blog.DB
|
||||
users.DB = blog.DB
|
||||
|
||||
// Initialize the router.
|
||||
|
@ -59,6 +66,9 @@ func New(documentRoot, userRoot string) *Blog {
|
|||
blog.AdminRoutes(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.NotFoundHandler = http.HandlerFunc(blog.PageHandler)
|
||||
|
||||
|
@ -66,6 +76,7 @@ func New(documentRoot, userRoot string) *Blog {
|
|||
negroni.NewRecovery(),
|
||||
negroni.NewLogger(),
|
||||
negroni.HandlerFunc(blog.SessionLoader),
|
||||
negroni.HandlerFunc(blog.CSRFMiddleware),
|
||||
negroni.HandlerFunc(blog.AuthMiddleware),
|
||||
)
|
||||
n.UseHandler(r)
|
||||
|
|
247
core/blog.go
247
core/blog.go
|
@ -1,19 +1,38 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/kirsle/blog/core/models/posts"
|
||||
"github.com/kirsle/blog/core/models/users"
|
||||
"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.
|
||||
func (b *Blog) BlogRoutes(r *mux.Router) {
|
||||
// Public routes
|
||||
r.HandleFunc("/blog", b.BlogIndex)
|
||||
|
||||
// Login-required routers.
|
||||
loginRouter := mux.NewRouter()
|
||||
loginRouter.HandleFunc("/blog/edit", b.EditBlog)
|
||||
loginRouter.HandleFunc("/blog/delete", b.DeletePost)
|
||||
r.PathPrefix("/blog").Handler(
|
||||
negroni.New(
|
||||
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.
|
||||
func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) {
|
||||
v := NewVars(map[interface{}]interface{}{
|
||||
"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 {
|
||||
// Parse from form values.
|
||||
post.LoadForm(r)
|
||||
post.ParseForm(r)
|
||||
|
||||
// Previewing, or submitting?
|
||||
switch r.FormValue("submit") {
|
||||
case "preview":
|
||||
if post.ContentType == "markdown" || post.ContentType == "markdown+html" {
|
||||
v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body))
|
||||
case "submit":
|
||||
} else {
|
||||
v.Data["preview"] = template.HTML(post.Body)
|
||||
}
|
||||
case "post":
|
||||
if err := post.Validate(); err != nil {
|
||||
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
|
||||
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") {
|
||||
name := strings.TrimSuffix(filePath, ".json")
|
||||
name := strings.TrimSuffix(dbPath, ".json")
|
||||
docs = append(docs, name)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,28 @@
|
|||
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 {
|
||||
output := github_flavored_markdown.Markdown([]byte(input))
|
||||
return string(output)
|
||||
unsafe := []byte(b.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 (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 (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/kirsle/blog/core/models/users"
|
||||
)
|
||||
|
@ -40,25 +42,54 @@ func (b *Blog) Session(r *http.Request) *sessions.Session {
|
|||
return session
|
||||
}
|
||||
|
||||
// CSRFMiddleware enforces CSRF tokens on all POST requests.
|
||||
func (b *Blog) CSRFMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
if r.Method == "POST" {
|
||||
session := b.Session(r)
|
||||
token, ok := session.Values["csrf"].(string)
|
||||
if !ok || token != r.FormValue("_csrf") {
|
||||
b.Forbidden(w, r, "Failed to validate CSRF token. Please try your request again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
|
||||
// GenerateCSRFToken generates a CSRF token for the user and puts it in their session.
|
||||
func (b *Blog) GenerateCSRFToken(w http.ResponseWriter, r *http.Request, session *sessions.Session) string {
|
||||
token, ok := session.Values["csrf"].(string)
|
||||
if !ok {
|
||||
token := uuid.New()
|
||||
session.Values["csrf"] = token.String()
|
||||
session.Save(r, w)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// CurrentUser returns the current user's object.
|
||||
func (b *Blog) CurrentUser(r *http.Request) (*users.User, error) {
|
||||
session := b.Session(r)
|
||||
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
||||
id := session.Values["user-id"].(int)
|
||||
u, err := users.LoadReadonly(id)
|
||||
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) {
|
||||
session := b.Session(r)
|
||||
log.Debug("AuthMiddleware() -- session values: %v", session.Values)
|
||||
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
||||
// They seem to be logged in. Get their user object.
|
||||
id := session.Values["user-id"].(int)
|
||||
u, err := users.Load(id)
|
||||
u, err := b.CurrentUser(r)
|
||||
if err != nil {
|
||||
log.Error("Error loading user ID %d from session: %v", id, err)
|
||||
log.Error("Error loading user from session: %v", err)
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userKey, u)
|
||||
next(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"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.
|
||||
type Post struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Fragment string `json:"fragment"`
|
||||
ContentType string `json:"contentType"`
|
||||
Body string `json:"body"`
|
||||
AuthorID int `json:"author"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Privacy string `json:"privacy"`
|
||||
Sticky bool `json:"sticky"`
|
||||
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.
|
||||
|
@ -29,8 +52,8 @@ func New() *Post {
|
|||
}
|
||||
}
|
||||
|
||||
// LoadForm populates the post from form values.
|
||||
func (p *Post) LoadForm(r *http.Request) {
|
||||
// ParseForm populates the post from form values.
|
||||
func (p *Post) ParseForm(r *http.Request) {
|
||||
id, _ := strconv.Atoi(r.FormValue("id"))
|
||||
|
||||
p.ID = id
|
||||
|
@ -64,3 +87,139 @@ func (p *Post) Validate() error {
|
|||
}
|
||||
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"`
|
||||
Name string `json:"name"`
|
||||
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.
|
||||
|
@ -58,6 +62,13 @@ func Create(u *User) error {
|
|||
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.
|
||||
func CheckAuth(username, password string) (*User, error) {
|
||||
username = Normalize(username)
|
||||
|
@ -118,8 +129,20 @@ func Load(id int) (*User, error) {
|
|||
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.
|
||||
func (u *User) Save() error {
|
||||
if u.readonly {
|
||||
return errors.New("user is read-only")
|
||||
}
|
||||
|
||||
// Sanity check that we have an ID.
|
||||
if u.ID == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// Handle the root URI with the blog index.
|
||||
if path == "/" {
|
||||
b.BlogIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Restrict special paths.
|
||||
if strings.HasPrefix(strings.ToLower(path), "/.") {
|
||||
b.Forbidden(w, r)
|
||||
|
@ -27,8 +33,12 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Search for a file that matches their URL.
|
||||
filepath, err := b.ResolvePath(path)
|
||||
if err != nil {
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
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.
|
||||
func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) {
|
||||
log.Error("HERE 3")
|
||||
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 {
|
||||
log.Error(err.Error())
|
||||
w.Write([]byte("Unrecoverable template error for Forbidden()"))
|
||||
|
|
|
@ -20,6 +20,8 @@ type Vars struct {
|
|||
Path string
|
||||
LoggedIn bool
|
||||
CurrentUser *users.User
|
||||
CSRF string
|
||||
Request *http.Request
|
||||
|
||||
// Common template variables.
|
||||
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") {
|
||||
v.SetupNeeded = true
|
||||
}
|
||||
v.Request = r
|
||||
v.Title = s.Site.Title
|
||||
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)
|
||||
}
|
||||
|
||||
v.CSRF = b.GenerateCSRFToken(w, r, session)
|
||||
|
||||
ctx := r.Context()
|
||||
if user, ok := ctx.Value(userKey).(*users.User); ok {
|
||||
if user.ID > 0 {
|
||||
|
@ -101,6 +106,7 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin
|
|||
log.Error("HERE!!!")
|
||||
t := template.New(filepath.Absolute).Funcs(template.FuncMap{
|
||||
"StringsJoin": strings.Join,
|
||||
"RenderPost": b.RenderPost,
|
||||
})
|
||||
|
||||
// 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 "layout" }}
|
||||
|
@ -8,11 +8,14 @@
|
|||
<meta charset="utf-8">
|
||||
<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 -->
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/bluez/theme.css">
|
||||
|
||||
<link rel="stylesheet" href="/css/blog-core.css">
|
||||
<!-- <link rel="stylesheet" href="/css/gfm.css"> -->
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{{ define "title" }}Website Settings{{ end }}
|
||||
{{ define "content" }}
|
||||
<form action="/admin/settings" method="POST">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||
<div class="card">
|
||||
{{ with .Data.s }}
|
||||
<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 "content" }}
|
||||
<form action="/blog/edit" method="POST">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||
{{ if .Data.preview }}
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<div class="card mb-5">
|
||||
<div class="card-header">
|
||||
Preview
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
@ -13,12 +14,11 @@
|
|||
{{ end }}
|
||||
|
||||
{{ with .Data.post }}
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h3>Update Blog</h3>
|
||||
|
||||
{{ . }}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<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>
|
||||
|
||||
<form method="POST" action="/initial-setup">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||
<div class="form-group">
|
||||
<label for="setup-admin-username">Admin username:</label>
|
||||
<input type="text"
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<h1>Sign In</h1>
|
||||
|
||||
<form name="login" action="/login" method="POST">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<input type="text" name="username" class="form-control" placeholder="Username">
|
||||
|
|
Loading…
Reference in New Issue
Block a user