Noah Petherbridge
6d3de7da69
Since most of the `render.Vars{}` fields were hardcoded/not really editable for the templates, apart from .Data, this struct is now locked away in the render subpackage. End http.HandlerFunc's can then make any arbitrary template data structure they want to, available inside the templates as `.Data`.
554 lines
14 KiB
Go
554 lines
14 KiB
Go
package core
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/feeds"
|
|
"github.com/gorilla/mux"
|
|
"github.com/kirsle/blog/core/internal/log"
|
|
"github.com/kirsle/blog/core/internal/markdown"
|
|
"github.com/kirsle/blog/core/internal/middleware/auth"
|
|
"github.com/kirsle/blog/core/internal/models/comments"
|
|
"github.com/kirsle/blog/core/internal/models/posts"
|
|
"github.com/kirsle/blog/core/internal/models/settings"
|
|
"github.com/kirsle/blog/core/internal/models/users"
|
|
"github.com/kirsle/blog/core/internal/render"
|
|
"github.com/kirsle/blog/core/internal/responses"
|
|
"github.com/urfave/negroni"
|
|
)
|
|
|
|
// PostMeta associates a Post with injected metadata.
|
|
type PostMeta struct {
|
|
Post *posts.Post
|
|
Rendered template.HTML
|
|
Author *users.User
|
|
NumComments int
|
|
IndexView bool
|
|
Snipped bool
|
|
}
|
|
|
|
// Archive holds data for a piece of the blog archive.
|
|
type Archive struct {
|
|
Label string
|
|
Date time.Time
|
|
Posts []posts.Post
|
|
}
|
|
|
|
// BlogRoutes attaches the blog routes to the app.
|
|
func (b *Blog) BlogRoutes(r *mux.Router) {
|
|
render.Funcs["RenderIndex"] = b.RenderIndex
|
|
render.Funcs["RenderPost"] = b.RenderPost
|
|
render.Funcs["RenderTags"] = b.RenderTags
|
|
|
|
// Public routes
|
|
r.HandleFunc("/blog", b.IndexHandler)
|
|
r.HandleFunc("/blog.rss", b.RSSHandler)
|
|
r.HandleFunc("/blog.atom", b.RSSHandler)
|
|
r.HandleFunc("/archive", b.BlogArchive)
|
|
r.HandleFunc("/tagged", b.Tagged)
|
|
r.HandleFunc("/tagged/{tag}", b.Tagged)
|
|
r.HandleFunc("/blog/category/{tag}", func(w http.ResponseWriter, r *http.Request) {
|
|
params := mux.Vars(r)
|
|
tag, ok := params["tag"]
|
|
if !ok {
|
|
b.NotFound(w, r, "Not Found")
|
|
return
|
|
}
|
|
responses.Redirect(w, "/tagged/"+tag)
|
|
})
|
|
r.HandleFunc("/blog/entry/{fragment}", func(w http.ResponseWriter, r *http.Request) {
|
|
params := mux.Vars(r)
|
|
fragment, ok := params["fragment"]
|
|
if !ok {
|
|
b.NotFound(w, r, "Not Found")
|
|
return
|
|
}
|
|
responses.Redirect(w, "/"+fragment)
|
|
})
|
|
|
|
// Login-required routers.
|
|
loginRouter := mux.NewRouter()
|
|
loginRouter.HandleFunc("/blog/edit", b.EditBlog)
|
|
loginRouter.HandleFunc("/blog/delete", b.DeletePost)
|
|
loginRouter.HandleFunc("/blog/drafts", b.Drafts)
|
|
loginRouter.HandleFunc("/blog/private", b.PrivatePosts)
|
|
r.PathPrefix("/blog").Handler(
|
|
negroni.New(
|
|
negroni.HandlerFunc(auth.LoginRequired(b.MustLogin)),
|
|
negroni.Wrap(loginRouter),
|
|
),
|
|
)
|
|
}
|
|
|
|
// RSSHandler renders an RSS feed from the blog.
|
|
func (b *Blog) RSSHandler(w http.ResponseWriter, r *http.Request) {
|
|
config, _ := settings.Load()
|
|
admin, err := users.Load(1)
|
|
if err != nil {
|
|
b.Error(w, r, "Blog isn't ready yet.")
|
|
return
|
|
}
|
|
|
|
feed := &feeds.Feed{
|
|
Title: config.Site.Title,
|
|
Link: &feeds.Link{Href: config.Site.URL},
|
|
Description: config.Site.Description,
|
|
Author: &feeds.Author{
|
|
Name: admin.Name,
|
|
Email: admin.Email,
|
|
},
|
|
Created: time.Now(),
|
|
}
|
|
|
|
feed.Items = []*feeds.Item{}
|
|
for i, p := range b.RecentPosts(r, "", "") {
|
|
post, _ := posts.Load(p.ID)
|
|
var suffix string
|
|
if strings.Contains(post.Body, "<snip>") {
|
|
post.Body = strings.Split(post.Body, "<snip>")[0]
|
|
suffix = "..."
|
|
}
|
|
|
|
feed.Items = append(feed.Items, &feeds.Item{
|
|
Title: p.Title,
|
|
Link: &feeds.Link{Href: config.Site.URL + p.Fragment},
|
|
Description: post.Body + suffix,
|
|
Created: p.Created,
|
|
})
|
|
if i >= 5 {
|
|
break
|
|
}
|
|
}
|
|
|
|
// What format to encode it in?
|
|
if strings.Contains(r.URL.Path, ".atom") {
|
|
atom, _ := feed.ToAtom()
|
|
w.Header().Set("Content-Type", "application/atom+xml")
|
|
w.Write([]byte(atom))
|
|
} else {
|
|
rss, _ := feed.ToRss()
|
|
w.Header().Set("Content-Type", "application/rss+xml")
|
|
w.Write([]byte(rss))
|
|
}
|
|
}
|
|
|
|
// IndexHandler renders the main index page of the blog.
|
|
func (b *Blog) IndexHandler(w http.ResponseWriter, r *http.Request) {
|
|
b.CommonIndexHandler(w, r, "", "")
|
|
}
|
|
|
|
// Tagged lets you browse blog posts by category.
|
|
func (b *Blog) Tagged(w http.ResponseWriter, r *http.Request) {
|
|
params := mux.Vars(r)
|
|
tag, ok := params["tag"]
|
|
if !ok {
|
|
// They're listing all the tags.
|
|
render.Template(w, r, "blog/tags.gohtml", nil)
|
|
return
|
|
}
|
|
|
|
b.CommonIndexHandler(w, r, tag, "")
|
|
}
|
|
|
|
// Drafts renders an index view of only draft posts. Login required.
|
|
func (b *Blog) Drafts(w http.ResponseWriter, r *http.Request) {
|
|
b.CommonIndexHandler(w, r, "", DRAFT)
|
|
}
|
|
|
|
// PrivatePosts renders an index view of only private posts. Login required.
|
|
func (b *Blog) PrivatePosts(w http.ResponseWriter, r *http.Request) {
|
|
b.CommonIndexHandler(w, r, "", PRIVATE)
|
|
}
|
|
|
|
// CommonIndexHandler handles common logic for blog index views.
|
|
func (b *Blog) CommonIndexHandler(w http.ResponseWriter, r *http.Request, tag, privacy string) {
|
|
// Page title.
|
|
var title string
|
|
if privacy == DRAFT {
|
|
title = "Draft Posts"
|
|
} else if privacy == PRIVATE {
|
|
title = "Private Posts"
|
|
} else if tag != "" {
|
|
title = "Tagged as: " + tag
|
|
} else {
|
|
title = "Blog"
|
|
}
|
|
|
|
render.Template(w, r, "blog/index", map[string]interface{}{
|
|
"Title": title,
|
|
"Tag": tag,
|
|
"Privacy": privacy,
|
|
})
|
|
}
|
|
|
|
// RecentPosts gets and filters the blog entries and orders them by most recent.
|
|
func (b *Blog) RecentPosts(r *http.Request, tag, privacy string) []posts.Post {
|
|
// Get the blog index.
|
|
idx, _ := posts.GetIndex()
|
|
|
|
// The set of blog posts to show.
|
|
var pool []posts.Post
|
|
for _, post := range idx.Posts {
|
|
// Limiting by a specific privacy setting? (drafts or private only)
|
|
if privacy != "" {
|
|
switch privacy {
|
|
case DRAFT:
|
|
if post.Privacy != DRAFT {
|
|
continue
|
|
}
|
|
case PRIVATE:
|
|
if post.Privacy != PRIVATE && post.Privacy != UNLISTED {
|
|
continue
|
|
}
|
|
}
|
|
} else {
|
|
// Exclude certain posts in generic index views.
|
|
if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !auth.LoggedIn(r) {
|
|
continue
|
|
} else if post.Privacy == DRAFT {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Limit by tag?
|
|
if tag != "" {
|
|
var tagMatch bool
|
|
if tag != "" {
|
|
for _, check := range post.Tags {
|
|
if check == tag {
|
|
tagMatch = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !tagMatch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
pool = append(pool, post)
|
|
}
|
|
|
|
sort.Sort(sort.Reverse(posts.ByUpdated(pool)))
|
|
return pool
|
|
}
|
|
|
|
// RenderIndex renders and returns the blog index partial.
|
|
func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML {
|
|
// Get the recent blog entries, filtered by the tag/privacy settings.
|
|
pool := b.RecentPosts(r, tag, privacy)
|
|
if len(pool) == 0 {
|
|
return template.HTML("No blog posts were found.")
|
|
}
|
|
|
|
// 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.
|
|
var previousPage, nextPage int
|
|
if page > 1 {
|
|
previousPage = page - 1
|
|
} else {
|
|
previousPage = 0
|
|
}
|
|
if offset+perPage < len(pool) {
|
|
nextPage = page + 1
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// Count the comments on this post.
|
|
var numComments int
|
|
if thread, err := comments.Load(fmt.Sprintf("post-%d", post.ID)); err == nil {
|
|
numComments = len(thread.Comments)
|
|
}
|
|
|
|
view = append(view, PostMeta{
|
|
Post: post,
|
|
Author: author,
|
|
NumComments: numComments,
|
|
})
|
|
}
|
|
|
|
// Render the blog index partial.
|
|
var output bytes.Buffer
|
|
v := map[string]interface{}{
|
|
"PreviousPage": previousPage,
|
|
"NextPage": nextPage,
|
|
"View": view,
|
|
}
|
|
render.Template(&output, r, "blog/index.partial", v)
|
|
|
|
return template.HTML(output.String())
|
|
}
|
|
|
|
// RenderTags renders the tags partial.
|
|
func (b *Blog) RenderTags(r *http.Request, indexView bool) template.HTML {
|
|
idx, err := posts.GetIndex()
|
|
if err != nil {
|
|
return template.HTML("[RenderTags: error getting blog index]")
|
|
}
|
|
|
|
tags, err := idx.Tags()
|
|
if err != nil {
|
|
return template.HTML("[RenderTags: error getting tags]")
|
|
}
|
|
|
|
var output bytes.Buffer
|
|
v := map[string]interface{}{
|
|
"IndexView": indexView,
|
|
"Tags": tags,
|
|
}
|
|
render.Template(&output, r, "blog/tags.partial", v)
|
|
|
|
return template.HTML(output.String())
|
|
}
|
|
|
|
// BlogArchive summarizes all blog entries in an archive view.
|
|
func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) {
|
|
idx, err := posts.GetIndex()
|
|
if err != nil {
|
|
b.BadRequest(w, r, "Error getting blog index")
|
|
return
|
|
}
|
|
|
|
// Group posts by calendar month.
|
|
var months []string
|
|
byMonth := map[string]*Archive{}
|
|
for _, post := range idx.Posts {
|
|
// Exclude certain posts
|
|
if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !auth.LoggedIn(r) {
|
|
continue
|
|
} else if post.Privacy == DRAFT {
|
|
continue
|
|
}
|
|
|
|
label := post.Created.Format("2006-01")
|
|
if _, ok := byMonth[label]; !ok {
|
|
months = append(months, label)
|
|
byMonth[label] = &Archive{
|
|
Label: label,
|
|
Date: time.Date(post.Created.Year(), post.Created.Month(), post.Created.Day(), 0, 0, 0, 0, time.UTC),
|
|
Posts: []posts.Post{},
|
|
}
|
|
}
|
|
byMonth[label].Posts = append(byMonth[label].Posts, post)
|
|
}
|
|
|
|
// Sort the months.
|
|
sort.Sort(sort.Reverse(sort.StringSlice(months)))
|
|
|
|
// Prepare the response.
|
|
result := []*Archive{}
|
|
for _, label := range months {
|
|
sort.Sort(sort.Reverse(posts.ByUpdated(byMonth[label].Posts)))
|
|
result = append(result, byMonth[label])
|
|
}
|
|
|
|
v := map[string]interface{}{
|
|
"Archive": result,
|
|
}
|
|
render.Template(w, r, "blog/archive", 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.
|
|
// Specifically, from the catch-all page handler to allow blog URL fragments
|
|
// to map to their post.
|
|
func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string) error {
|
|
post, err := posts.LoadFragment(fragment)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Handle post privacy.
|
|
if post.Privacy == PRIVATE || post.Privacy == DRAFT {
|
|
if !auth.LoggedIn(r) {
|
|
b.NotFound(w, r, "That post is not public.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
v := map[string]interface{}{
|
|
"Post": post,
|
|
}
|
|
render.Template(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(r *http.Request, p *posts.Post, indexView bool, numComments int) 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>") {
|
|
parts := strings.SplitN(p.Body, "<snip>", 2)
|
|
p.Body = parts[0]
|
|
snipped = true
|
|
}
|
|
}
|
|
|
|
p.Body = strings.Replace(p.Body, "<snip>", "<div id=\"snip\"></div>", 1)
|
|
|
|
// Render the post to HTML.
|
|
var rendered template.HTML
|
|
if p.ContentType == string(MARKDOWN) {
|
|
rendered = template.HTML(markdown.RenderTrustedMarkdown(p.Body))
|
|
} else {
|
|
rendered = template.HTML(p.Body)
|
|
}
|
|
|
|
meta := map[string]interface{}{
|
|
"Post": p,
|
|
"Rendered": rendered,
|
|
"Author": author,
|
|
"IndexView": indexView,
|
|
"Snipped": snipped,
|
|
"NumComments": numComments,
|
|
}
|
|
output := bytes.Buffer{}
|
|
err = render.Template(&output, r, "blog/entry.partial", meta)
|
|
if err != nil {
|
|
return template.HTML(fmt.Sprintf("[template error in blog/entry.partial: %s]", err.Error()))
|
|
}
|
|
|
|
return template.HTML(output.String())
|
|
}
|
|
|
|
// EditBlog is the blog writing and editing page.
|
|
func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) {
|
|
v := map[string]interface{}{
|
|
"preview": "",
|
|
}
|
|
var post *posts.Post
|
|
|
|
// Are we editing an existing post?
|
|
if idStr := r.FormValue("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.ParseForm(r)
|
|
|
|
// Previewing, or submitting?
|
|
switch r.FormValue("submit") {
|
|
case "preview":
|
|
if post.ContentType == string(MARKDOWN) {
|
|
v["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body))
|
|
} else {
|
|
v["preview"] = template.HTML(post.Body)
|
|
}
|
|
case "post":
|
|
if err := post.Validate(); err != nil {
|
|
v["Error"] = err
|
|
} else {
|
|
author, _ := auth.CurrentUser(r)
|
|
post.AuthorID = author.ID
|
|
|
|
post.Updated = time.Now().UTC()
|
|
err = post.Save()
|
|
if err != nil {
|
|
v["Error"] = err
|
|
} else {
|
|
responses.Flash(w, r, "Post created!")
|
|
responses.Redirect(w, "/"+post.Fragment)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
v["post"] = post
|
|
render.Template(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 := map[string]interface{}{
|
|
"Post": nil,
|
|
}
|
|
|
|
var idStr string
|
|
if r.Method == http.MethodPost {
|
|
idStr = r.FormValue("id")
|
|
} else {
|
|
idStr = r.URL.Query().Get("id")
|
|
}
|
|
if idStr == "" {
|
|
responses.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 {
|
|
responses.FlashAndRedirect(w, r, "/admin", "That post ID was not found.")
|
|
return
|
|
}
|
|
}
|
|
|
|
if r.Method == http.MethodPost {
|
|
post.Delete()
|
|
responses.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!")
|
|
return
|
|
}
|
|
|
|
v["Post"] = post
|
|
render.Template(w, r, "blog/delete", v)
|
|
}
|