Controller breakout for blog and comments

This commit is contained in:
Noah 2018-02-10 15:07:10 -08:00
parent eb1880d348
commit c69c14ea09
15 changed files with 747 additions and 661 deletions

View File

@ -1,553 +0,0 @@
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)
}

View File

@ -9,7 +9,9 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/kirsle/blog/core/internal/controllers/admin" "github.com/kirsle/blog/core/internal/controllers/admin"
"github.com/kirsle/blog/core/internal/controllers/authctl" "github.com/kirsle/blog/core/internal/controllers/authctl"
commentctl "github.com/kirsle/blog/core/internal/controllers/comments"
"github.com/kirsle/blog/core/internal/controllers/contact" "github.com/kirsle/blog/core/internal/controllers/contact"
postctl "github.com/kirsle/blog/core/internal/controllers/posts"
"github.com/kirsle/blog/core/internal/controllers/setup" "github.com/kirsle/blog/core/internal/controllers/setup"
"github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/log"
"github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/markdown"
@ -113,9 +115,9 @@ func (b *Blog) SetupHTTP() {
setup.Register(r) setup.Register(r)
authctl.Register(r) authctl.Register(r)
admin.Register(r, b.MustLogin) admin.Register(r, b.MustLogin)
contact.Register(r, b.Error) contact.Register(r)
b.BlogRoutes(r) postctl.Register(r, b.MustLogin)
b.CommentRoutes(r) commentctl.Register(r)
// GitHub Flavored Markdown CSS. // GitHub Flavored Markdown CSS.
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))
@ -127,7 +129,7 @@ func (b *Blog) SetupHTTP() {
negroni.NewRecovery(), negroni.NewRecovery(),
negroni.NewLogger(), negroni.NewLogger(),
negroni.HandlerFunc(sessions.Middleware), negroni.HandlerFunc(sessions.Middleware),
negroni.HandlerFunc(middleware.CSRF(b.Forbidden)), negroni.HandlerFunc(middleware.CSRF(responses.Forbidden)),
negroni.HandlerFunc(auth.Middleware), negroni.HandlerFunc(auth.Middleware),
) )
n.UseHandler(r) n.UseHandler(r)

View File

@ -5,10 +5,12 @@ import (
"github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/log"
"github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/core/internal/render"
"github.com/kirsle/blog/core/internal/responses"
) )
// NotFound sends a 404 response. // registerErrors loads the error handlers into the responses subpackage.
func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message string) { func (b *Blog) registerErrors() {
responses.NotFound = func(w http.ResponseWriter, r *http.Request, message string) {
if message == "" { if message == "" {
message = "The page you were looking for was not found." message = "The page you were looking for was not found."
} }
@ -23,8 +25,7 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message string)
} }
} }
// Forbidden sends an HTTP 403 Forbidden response. responses.Forbidden = func(w http.ResponseWriter, r *http.Request, message string) {
func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message string) {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
err := render.Template(w, r, ".errors/403", map[string]string{ err := render.Template(w, r, ".errors/403", map[string]string{
"Message": message, "Message": message,
@ -35,8 +36,7 @@ func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message string)
} }
} }
// Error sends an HTTP 500 Internal Server Error response. responses.Error = func(w http.ResponseWriter, r *http.Request, message string) {
func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message string) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
err := render.Template(w, r, ".errors/500", map[string]string{ err := render.Template(w, r, ".errors/500", map[string]string{
"Message": message, "Message": message,
@ -47,8 +47,7 @@ func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message string) {
} }
} }
// BadRequest sends an HTTP 400 Bad Request. responses.BadRequest = func(w http.ResponseWriter, r *http.Request, message string) {
func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message string) {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
err := render.Template(w, r, ".errors/400", map[string]string{ err := render.Template(w, r, ".errors/400", map[string]string{
"Message": message, "Message": message,
@ -58,3 +57,5 @@ func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message string
w.Write([]byte("Unrecoverable template error for BadRequest()")) w.Write([]byte("Unrecoverable template error for BadRequest()"))
} }
} }
}

View File

@ -1,4 +1,4 @@
package core package comments
import ( import (
"bytes" "bytes"
@ -19,13 +19,16 @@ import (
"github.com/kirsle/blog/core/internal/sessions" "github.com/kirsle/blog/core/internal/sessions"
) )
// CommentRoutes attaches the comment routes to the app. var badRequest func(http.ResponseWriter, *http.Request, string)
func (b *Blog) CommentRoutes(r *mux.Router) {
render.Funcs["RenderComments"] = b.RenderComments
r.HandleFunc("/comments", b.CommentHandler) // Register the comment routes to the app.
r.HandleFunc("/comments/subscription", b.SubscriptionHandler) func Register(r *mux.Router) {
r.HandleFunc("/comments/quick-delete", b.QuickDeleteHandler) badRequest = responses.BadRequest
render.Funcs["RenderComments"] = RenderComments
r.HandleFunc("/comments", commentHandler)
r.HandleFunc("/comments/subscription", subscriptionHandler)
r.HandleFunc("/comments/quick-delete", quickDeleteHandler)
} }
// CommentMeta is the template variables for comment threads. // CommentMeta is the template variables for comment threads.
@ -40,7 +43,7 @@ type CommentMeta struct {
} }
// RenderComments renders a comment form partial and returns the HTML. // RenderComments renders a comment form partial and returns the HTML.
func (b *Blog) RenderComments(r *http.Request, subject string, ids ...string) template.HTML { func RenderComments(r *http.Request, subject string, ids ...string) template.HTML {
id := strings.Join(ids, "-") id := strings.Join(ids, "-")
session := sessions.Get(r) session := sessions.Get(r)
url := r.URL.Path url := r.URL.Path
@ -141,14 +144,13 @@ func (b *Blog) RenderComments(r *http.Request, subject string, ids ...string) te
return template.HTML(output.String()) return template.HTML(output.String())
} }
// CommentHandler handles the /comments URI for previewing and posting. func commentHandler(w http.ResponseWriter, r *http.Request) {
func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
b.BadRequest(w, r, "That method is not allowed.") badRequest(w, r, "That method is not allowed.")
return return
} }
currentUser, _ := auth.CurrentUser(r) currentUser, _ := auth.CurrentUser(r)
editToken := b.GetEditToken(w, r) editToken := getEditToken(w, r)
submit := r.FormValue("submit") submit := r.FormValue("submit")
// Load the comment data from the form. // Load the comment data from the form.
@ -207,14 +209,14 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
// Previewing, deleting, or posting? // Previewing, deleting, or posting?
switch submit { switch submit {
case ActionPreview, ActionDelete: case "preview", "delete":
if !c.Editing && currentUser.IsAuthenticated { if !c.Editing && currentUser.IsAuthenticated {
c.Name = currentUser.Name c.Name = currentUser.Name
c.Email = currentUser.Email c.Email = currentUser.Email
c.LoadAvatar() c.LoadAvatar()
} }
c.HTML = template.HTML(markdown.RenderMarkdown(c.Body)) c.HTML = template.HTML(markdown.RenderMarkdown(c.Body))
case ActionPost: case "post":
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
v["Error"] = err v["Error"] = err
} else { } else {
@ -258,55 +260,22 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
v["Thread"] = t v["Thread"] = t
v["Comment"] = c v["Comment"] = c
v["Editing"] = c.Editing v["Editing"] = c.Editing
v["Deleting"] = submit == ActionDelete v["Deleting"] = submit == "delete"
render.Template(w, r, "comments/index.gohtml", v) render.Template(w, r, "comments/index.gohtml", v)
} }
// SubscriptionHandler to opt out of subscriptions. func quickDeleteHandler(w http.ResponseWriter, r *http.Request) {
func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) {
// POST to unsubscribe from all threads.
if r.Method == http.MethodPost {
email := r.FormValue("email")
if email == "" {
b.BadRequest(w, r, "email address is required to unsubscribe from comment threads")
} else if _, err := mail.ParseAddress(email); err != nil {
b.BadRequest(w, r, "invalid email address")
}
m := comments.LoadMailingList()
m.UnsubscribeAll(email)
responses.FlashAndRedirect(w, r, "/comments/subscription",
"You have been unsubscribed from all mailing lists.",
)
return
}
// GET to unsubscribe from a single thread.
thread := r.URL.Query().Get("t")
email := r.URL.Query().Get("e")
if thread != "" && email != "" {
m := comments.LoadMailingList()
m.Unsubscribe(thread, email)
responses.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.")
return
}
render.Template(w, r, "comments/subscription.gohtml", nil)
}
// QuickDeleteHandler allows the admin to quickly delete spam without logging in.
func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) {
thread := r.URL.Query().Get("t") thread := r.URL.Query().Get("t")
token := r.URL.Query().Get("d") token := r.URL.Query().Get("d")
if thread == "" || token == "" { if thread == "" || token == "" {
b.BadRequest(w, r, "Bad Request") badRequest(w, r, "Bad Request")
return return
} }
t, err := comments.Load(thread) t, err := comments.Load(thread)
if err != nil { if err != nil {
b.BadRequest(w, r, "Comment thread does not exist.") badRequest(w, r, "Comment thread does not exist.")
return return
} }
@ -317,9 +286,9 @@ func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) {
responses.FlashAndRedirect(w, r, "/", "Comment deleted!") responses.FlashAndRedirect(w, r, "/", "Comment deleted!")
} }
// GetEditToken gets or generates an edit token from the user's session, which // getEditToken gets or generates an edit token from the user's session, which
// allows a user to edit their comment for a short while after they post it. // allows a user to edit their comment for a short while after they post it.
func (b *Blog) GetEditToken(w http.ResponseWriter, r *http.Request) string { func getEditToken(w http.ResponseWriter, r *http.Request) string {
session := sessions.Get(r) session := sessions.Get(r)
if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 { if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 {
return token return token

View File

@ -0,0 +1,41 @@
package comments
import (
"net/http"
"net/mail"
"github.com/kirsle/blog/core/internal/models/comments"
"github.com/kirsle/blog/core/internal/render"
"github.com/kirsle/blog/core/internal/responses"
)
func subscriptionHandler(w http.ResponseWriter, r *http.Request) {
// POST to unsubscribe from all threads.
if r.Method == http.MethodPost {
email := r.FormValue("email")
if email == "" {
badRequest(w, r, "email address is required to unsubscribe from comment threads")
} else if _, err := mail.ParseAddress(email); err != nil {
badRequest(w, r, "invalid email address")
}
m := comments.LoadMailingList()
m.UnsubscribeAll(email)
responses.FlashAndRedirect(w, r, "/comments/subscription",
"You have been unsubscribed from all mailing lists.",
)
return
}
// GET to unsubscribe from a single thread.
thread := r.URL.Query().Get("t")
email := r.URL.Query().Get("e")
if thread != "" && email != "" {
m := comments.LoadMailingList()
m.Unsubscribe(thread, email)
responses.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.")
return
}
render.Template(w, r, "comments/subscription.gohtml", nil)
}

View File

@ -18,7 +18,7 @@ import (
) )
// Register attaches the contact URL to the app. // Register attaches the contact URL to the app.
func Register(r *mux.Router, onError func(http.ResponseWriter, *http.Request, string)) { func Register(r *mux.Router) {
r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) {
form := &forms.Contact{} form := &forms.Contact{}
v := map[string]interface{}{ v := map[string]interface{}{
@ -28,13 +28,13 @@ func Register(r *mux.Router, onError func(http.ResponseWriter, *http.Request, st
// If there is no site admin, show an error. // If there is no site admin, show an error.
cfg, err := settings.Load() cfg, err := settings.Load()
if err != nil { if err != nil {
onError(w, r, "Error loading site configuration!") responses.Error(w, r, "Error loading site configuration!")
return return
} else if cfg.Site.AdminEmail == "" { } else if cfg.Site.AdminEmail == "" {
onError(w, r, "There is no admin email configured for this website!") responses.Error(w, r, "There is no admin email configured for this website!")
return return
} else if !cfg.Mail.Enabled { } else if !cfg.Mail.Enabled {
onError(w, r, "This website doesn't have an e-mail gateway configured.") responses.Error(w, r, "This website doesn't have an e-mail gateway configured.")
return return
} }

View File

@ -0,0 +1,60 @@
package postctl
import (
"net/http"
"sort"
"time"
"github.com/kirsle/blog/core/internal/middleware/auth"
"github.com/kirsle/blog/core/internal/models/posts"
"github.com/kirsle/blog/core/internal/render"
"github.com/kirsle/blog/core/internal/responses"
"github.com/kirsle/blog/core/internal/types"
)
// archiveHandler summarizes all blog entries in an archive view.
func archiveHandler(w http.ResponseWriter, r *http.Request) {
idx, err := posts.GetIndex()
if err != nil {
responses.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 == types.PRIVATE || post.Privacy == types.UNLISTED) && !auth.LoggedIn(r) {
continue
} else if post.Privacy == types.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)
}

View File

@ -0,0 +1,110 @@
package postctl
import (
"errors"
"html/template"
"net/http"
"strconv"
"time"
"github.com/kirsle/blog/core/internal/markdown"
"github.com/kirsle/blog/core/internal/middleware/auth"
"github.com/kirsle/blog/core/internal/models/posts"
"github.com/kirsle/blog/core/internal/render"
"github.com/kirsle/blog/core/internal/responses"
"github.com/kirsle/blog/core/internal/types"
)
// editHandler is the blog writing and editing page.
func editHandler(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(types.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)
}
// deleteHandler to delete a blog entry.
func deleteHandler(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)
}

View File

@ -0,0 +1,64 @@
package postctl
import (
"net/http"
"strings"
"time"
"github.com/gorilla/feeds"
"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/responses"
)
func feedHandler(w http.ResponseWriter, r *http.Request) {
config, _ := settings.Load()
admin, err := users.Load(1)
if err != nil {
responses.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 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))
}
}

View File

@ -0,0 +1,125 @@
package postctl
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strconv"
"github.com/kirsle/blog/core/internal/log"
"github.com/kirsle/blog/core/internal/models/comments"
"github.com/kirsle/blog/core/internal/models/posts"
"github.com/kirsle/blog/core/internal/models/users"
"github.com/kirsle/blog/core/internal/render"
"github.com/kirsle/blog/core/internal/types"
)
// partialIndex renders and returns the blog index partial.
func partialIndex(r *http.Request, tag, privacy string) template.HTML {
// Get the recent blog entries, filtered by the tag/privacy settings.
pool := 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())
}
// indexHandler renders the main index page of the blog.
func indexHandler(w http.ResponseWriter, r *http.Request) {
commonIndexHandler(w, r, "", "")
}
// drafts renders an index view of only draft posts. Login required.
func drafts(w http.ResponseWriter, r *http.Request) {
commonIndexHandler(w, r, "", types.DRAFT)
}
// privatePosts renders an index view of only private posts. Login required.
func privatePosts(w http.ResponseWriter, r *http.Request) {
commonIndexHandler(w, r, "", types.PRIVATE)
}
// commonIndexHandler handles common logic for blog index views.
func commonIndexHandler(w http.ResponseWriter, r *http.Request, tag, privacy string) {
// Page title.
var title string
if privacy == types.DRAFT {
title = "Draft Posts"
} else if privacy == types.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,
})
}

View File

@ -0,0 +1,212 @@
package postctl
import (
"bytes"
"fmt"
"html/template"
"net/http"
"sort"
"strings"
"time"
"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/posts"
"github.com/kirsle/blog/core/internal/models/users"
"github.com/kirsle/blog/core/internal/render"
"github.com/kirsle/blog/core/internal/responses"
"github.com/kirsle/blog/core/internal/types"
"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
}
// Register the blog routes to the app.
func Register(r *mux.Router, loginError http.HandlerFunc) {
render.Funcs["RenderIndex"] = partialIndex
render.Funcs["RenderPost"] = partialPost
render.Funcs["RenderTags"] = partialTags
// Public routes
r.HandleFunc("/blog", indexHandler)
r.HandleFunc("/blog.rss", feedHandler)
r.HandleFunc("/blog.atom", feedHandler)
r.HandleFunc("/archive", archiveHandler)
r.HandleFunc("/tagged", taggedHandler)
r.HandleFunc("/tagged/{tag}", taggedHandler)
r.HandleFunc("/blog/category/{tag}", func(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
tag, ok := params["tag"]
if !ok {
responses.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 {
responses.NotFound(w, r, "Not Found")
return
}
responses.Redirect(w, "/"+fragment)
})
// Login-required routers.
loginRouter := mux.NewRouter()
loginRouter.HandleFunc("/blog/edit", editHandler)
loginRouter.HandleFunc("/blog/delete", deleteHandler)
loginRouter.HandleFunc("/blog/drafts", drafts)
loginRouter.HandleFunc("/blog/private", privatePosts)
r.PathPrefix("/blog").Handler(
negroni.New(
negroni.HandlerFunc(auth.LoginRequired(loginError)),
negroni.Wrap(loginRouter),
),
)
}
// RecentPosts gets and filters the blog entries and orders them by most recent.
func 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 types.DRAFT:
if post.Privacy != types.DRAFT {
continue
}
case types.PRIVATE:
if post.Privacy != types.PRIVATE && post.Privacy != types.UNLISTED {
continue
}
}
} else {
// Exclude certain posts in generic index views.
if (post.Privacy == types.PRIVATE || post.Privacy == types.UNLISTED) && !auth.LoggedIn(r) {
continue
} else if post.Privacy == types.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
}
// 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 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 == types.PRIVATE || post.Privacy == types.DRAFT {
if !auth.LoggedIn(r) {
responses.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
}
// partialPost 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 partialPost(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(types.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())
}

View File

@ -0,0 +1,46 @@
package postctl
import (
"bytes"
"html/template"
"net/http"
"github.com/gorilla/mux"
"github.com/kirsle/blog/core/internal/models/posts"
"github.com/kirsle/blog/core/internal/render"
)
// tagged lets you browse blog posts by category.
func taggedHandler(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
}
commonIndexHandler(w, r, tag, "")
}
// partialTags renders the tags partial.
func partialTags(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())
}

View File

@ -7,6 +7,14 @@ import (
"github.com/kirsle/blog/core/internal/sessions" "github.com/kirsle/blog/core/internal/sessions"
) )
// Error handlers to be filled in by the blog app.
var (
NotFound func(http.ResponseWriter, *http.Request, string)
Forbidden func(http.ResponseWriter, *http.Request, string)
BadRequest func(http.ResponseWriter, *http.Request, string)
Error func(http.ResponseWriter, *http.Request, string)
)
// Flash adds a flash message to the user's session. // Flash adds a flash message to the user's session.
func Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { func Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) {
session := sessions.Get(r) session := sessions.Get(r)

View File

@ -1,4 +1,4 @@
package core package types
// PostPrivacy values. // PostPrivacy values.
type PostPrivacy string type PostPrivacy string

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/kirsle/blog/core/internal/controllers/posts"
"github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/markdown"
"github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/core/internal/render"
"github.com/kirsle/blog/core/internal/responses" "github.com/kirsle/blog/core/internal/responses"
@ -24,7 +25,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
// Restrict special paths. // Restrict special paths.
if strings.HasPrefix(strings.ToLower(path), "/.") { if strings.HasPrefix(strings.ToLower(path), "/.") {
b.Forbidden(w, r, "Forbidden") responses.Forbidden(w, r, "Forbidden")
return return
} }
@ -32,9 +33,9 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
filepath, err := render.ResolvePath(path) filepath, err := render.ResolvePath(path)
if err != nil { if err != nil {
// See if it resolves as a blog entry. // See if it resolves as a blog entry.
err = b.viewPost(w, r, strings.TrimLeft(path, "/")) err = postctl.ViewPost(w, r, strings.TrimLeft(path, "/"))
if err != nil { if err != nil {
b.NotFound(w, r, "The page you were looking for was not found.") responses.NotFound(w, r, "The page you were looking for was not found.")
} }
return return
} }
@ -49,7 +50,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(filepath.URI, ".md") || strings.HasSuffix(filepath.URI, ".markdown") { if strings.HasSuffix(filepath.URI, ".md") || strings.HasSuffix(filepath.URI, ".markdown") {
source, err := ioutil.ReadFile(filepath.Absolute) source, err := ioutil.ReadFile(filepath.Absolute)
if err != nil { if err != nil {
b.Error(w, r, "Couldn't read Markdown source!") responses.Error(w, r, "Couldn't read Markdown source!")
return return
} }